|
23269
|
985
|
15
|
2026-05-12T07:42:18.970152+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-12/1778 /Users/lukas/.screenpipe/data/data/2026-05-12/1778571738970_m2.jpg...
|
PhpStorm
|
faVsco.js – PlanhatService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"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":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.8394282,"top":0.019952115,"width":0.076130316,"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 'HandleHubspotRateLimitTest'","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 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"bounds":{"left":0.3882979,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.39993352,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4112367,"top":0.074221864,"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.41855052,"top":0.074221864,"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;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.0726257,"width":0.30319148,"height":0.9273743},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.7124335,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.7237367,"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.73105055,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.44481382,"top":0.09736632,"width":0.55518615,"height":0.8818835},"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
8167272953559082915
|
-7714420151459628056
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
23254
|
985
|
7
|
2026-05-12T07:41:41.991896+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-12/1778 /Users/lukas/.screenpipe/data/data/2026-05-12/1778571701991_m2.jpg...
|
PhpStorm
|
faVsco.js – PlanhatService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"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":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.8394282,"top":0.019952115,"width":0.076130316,"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 'HandleHubspotRateLimitTest'","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}]...
|
-6522339035140287979
|
-8348248415050200704
|
app_switch
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
PhostormVIewINavicareCodeLaravelKeractorTOOISWindowFV faVsco.js?9 JY-20725-handle-HS-search-rate-IiroledeyC) AutomatedReportGenerated.php© PlaybackController.php(1) MatchDomainBvEmail© OpportunityActivityMa© OpportunitySyncStrate© OpportunitySyncStrate© ProspectCache.php€ ProspectSearchScope© ProspectSearchStrate© ProspectSearchStratec Providerkegistry.pnp€ RecordSelector.phpkesolvecompanyNamc) limererioaiterator.phglimoonuInternal0 Kioskv AutomatedReportsC) ActivitvivoeServiceC) AskJiminnvReportA(C) AutomatedRenortsiC) AutomatedRenorts.(C) DealStadesServiceC) RecioientsService.rE) ReportSort.ohrE) RenortSortDirection(C) KioskService.oho1M Mailom MeptinaGenerator1 NotificationM0Auth2M PecallA1 SecurnityD StrategyD Streaming_ leamD TelephonvD UserPilot0 Webhook© AbstractService.php© ActivityProviderFactory.p© ActivitvService.php(C) AoiResoonseService.oho© ConferenceService.ohn(C)InsiahtSeatService.oho(C)InstantMeetinaService.oh(C)IntercomService.oho© InapiClient.php©) IoaniService.ohr@ DarticinantShareService r 1201reaconly class Plannatservice(@) PlanhatService nhr@ DlavhackService nhnYC) PlavhackViden@nlvServic 127(C) DlavbaskCotoaan.c.ndpublic function -_constructtprivate Rolestatsrepository srolestatsrepository.) (...7/** othrows Guzzle xception */oubuc function trackuser suser strind Sevent, arrav soavload = : vord1f @ Sthis-›servicelsAvailable(Suser->getTeamO->qetPartnerido0)«return:Susen->inadld relations: "team!)Sdata =fInamel => Susen->aetNameollemaili = Susen->aptFmailAddnecs0ll'externalId' => $user->getUuid.'companyExternalId' => Suser->getTeam->getUuidaction = sevent,'info' => $payload.SplanhatResponse = Http::planhatAnaluticsApiO->post url:'analytics/'. confia( key:'services.planhat.tenantUuid'). Sdata):Sthis->loqFailedResponses(SplanhatResponse.message: METHOD"body => solanhatresponse->sonlo'status' => $planhatResponse->status@'data' => Sdatal** athrows Guzzesycention */nublic function meten(lisen Suser. string Sdimension. strina Svalue)• voidf...?** Athrows GuzzleSycention */public function upsertCompany(Team Steam): voidi...}* Athnowe Guz>leSycontion* @throws BindingResolutionExceptionpublic function upsertUser(User Suser): void{...}hel"suppont Dally • In 4n 15 mPlanhatService.php x:12 v.19 ^= laravel.l0gA SF (jiminny@localhost]4 HS_local jiminny@localhost]A console [STAGING][2026-05-07 14:21:15] local.INF0: [Hubspot] DEBUG Getting headers {"neaders.?"Vace".L"Inu,or May 2020 14.21.15 bMl"J,"Transter-Encodinq":"chunked").# console [PKol)# console [eu)cascadePlanhat Event PlaybacThoughtsSearched planhat in ~/iminnylappThoughtsSearched olavback *visitedivisited."olavback in ~/fiminnvlaoo.100% 5• Tue 12 May 10:41:41+O •find planhat event playback visitedСОЛРРВРДВВВВВВВВВВВВВВВО"CF-Ray": ["9f80deb8db60dc3a-SOF"]."Strict-Transport-Security":["max-aqe=31536000: includeSubDomains: preload"]."server-timing": ["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\","x-content-tvoe-ootions":"nosniff"."x-hubsoot-correlation-id":"019e02d0-6fd8-7812-bdba-885b7ccb3ee3"7."Set-Cookie":["__cf_bm=StUrtdQgXVnik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfW07-May-26 14:51:15 GMT; domain=.hubapi.com; Http0nly; Secure; SameSite=None"),"Renont-To".f"s"endnoints".'\"url\":"https:\V/\V/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIx\"group\":\"cf-nel\",\"max_age\":604800}"],"NEL":["{\"success_fraction\":0.01,report to. "cr-nel,|"max agel ":604800}"]"Server": ["cloudflare"]}} {"correlation 1d":"95256555-ec98-4541-b9za-adta/sboyeab"."trace_10":"C/A8565-905t-4604-9405-0e5b551e5545',Soarched pl.Searched visited in aprThouahtsFound it in PlaybackController.php :Read DlavbackController.nhn #| 85-114Location:Plavbackcontroller.0no:95-168oho.*Cendina InlavbackVicitedl event to Planhat without clowina +hedeferlfn = Sthis->olanhatService-strack(user. Suserpayload: ['activityld' = Sactivity->getId(),activitvluid' = sactivity-saetluido.-salwavs@)Contovt. Collod in tha niavhaak nado controllor wronnod in doforl to firo tha Dlanhat avant scunchronduely aftor thoHTTP response is sent to avoid slowina down the user experience.Oal *Jed daily usage quota is exhausted. Purchase extra usage to continue usina premium models. Quota resets May 12, 11:00"AN CUTYAsk anvthina (84L)÷ @Code SWF-1.620-17JUTE.Afo 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
23253
|
984
|
7
|
2026-05-12T07:41:41.995174+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-12/1778 /Users/lukas/.screenpipe/data/data/2026-05-12/1778571701995_m1.jpg...
|
PhpStorm
|
faVsco.js – PlanhatService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-6522339035140287979
|
-8348248415050200704
|
app_switch
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
SlackFileEditViewGoHistoryWindowHelplnlolSupport Daily • in 4 h 19 mDOCKERO ₴1DEV (-zsh)• ₴2APP (-zsh)whisper_init_state:kv crosssize =9.44 MBwhisper_init_state:kv padsize2.36 MBwhisper_init_state:compute buffer (conv)whisper_init_state:compute buffer (encode) =whisper_init_state:compute buffer (cross)whisper_init_state:computebuffer (decode) =14.17 MB65.96 MB8.50 MB96.83 MBggml_metal_free: deallocatingwhisper_backend_init_gpu:device 0: Metal (type:1)whisper_backend_init_gpu:found GPU device 0: Metal (type: 1, cnt:0)whisper_backend_init_gpu: using Metal backendggml_metal_init: allocatingggml_metal_init:founddevice:Apple M1ggml_metal_init:pickingdefault device: Apple M1ggml_metal_init:use fusion= trueggml_metal_init:use concurrency= trueggml_metal_init: use graph optimizetruewhisper_backend_init: using BLAS backendwhisper_init_state: kv selfsize3.15 MBwhisper_init_state: kv cross size =9.44 MBwhisper_init_state: kv padsize=2.36 MBwhisper_init_state: compute buffer (conv)whisper_init_state: compute buffer (encode) =whisper_init_state: compute buffer (cross)=whisper_init_state: compute buffer (decode) =14.17 MB65.96 MB8.50 MB96.83 MBggml_metal_free: deallocatingwhisper_backend_init_gpu: device 0: Metal (type:1)whisper_backend_init_gpu: found GPU device 0: Metal (type: 1, cnt: 0)whisper_backend_init_gpu: using Metal backendggml_metal_init: allocatingggml_metal_init: found device: Apple M1ggml_metal_init: picking default device: Apple M1ggml_metal_init:use fusion= trueggml_metal_init: use concurrency= trueggml_metal_init: use graph optimize= truewhisper_backend_init: using BLAS backendwhisper_init_state: kv self size=3.15 MBwhisper_init_state: kv cross size =9.44 MBwhisper_init_state: kv padsize=2.36 MBwhisper_init_state: compute buffer (conv)whisper_init_state: compute buffer (encode) =whisper_init_state: compute buffer (cross)whisper_init_state: compute buffer (decode) =14.17 MB65.96 MB8.50 MB96.83 MBggml_metal_free: deallocatingwhisper_backend_init_gpu: device 0: Metal (type: 1)whisper_backend_init_gpu: found GPU device 0: Metal (type: 1, cnt: 0)H3screenpipe"-zsh84-zsh*5screenpipe"100% <78• Tue 12 May 10:41:41T81786-zsh₴7+...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
22924
|
979
|
15
|
2026-05-12T07:27:04.760551+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-12/1778 /Users/lukas/.screenpipe/data/data/2026-05-12/1778570824760_m2.jpg...
|
PhpStorm
|
faVsco.js – PlanhatService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"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":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.8394282,"top":0.019952115,"width":0.076130316,"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 'HandleHubspotRateLimitTest'","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 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"bounds":{"left":0.3882979,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.39993352,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4112367,"top":0.074221864,"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.41855052,"top":0.074221864,"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;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.0726257,"width":0.30319148,"height":0.9273743},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
-6526765115672585581
|
-7759455890035295252
|
app_switch
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
22923
|
978
|
33
|
2026-05-12T07:27:04.760240+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-12/1778 /Users/lukas/.screenpipe/data/data/2026-05-12/1778570824760_m1.jpg...
|
PhpStorm
|
faVsco.js – PlanhatService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}
Sync Changes
Hide This Notification...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"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}]...
|
-6488225921241527851
|
-7759464961006224404
|
app_switch
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}
Sync Changes
Hide This Notification...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
22860
|
977
|
58
|
2026-05-12T07:23:50.440887+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-12/1778 /Users/lukas/.screenpipe/data/data/2026-05-12/1778570630440_m2.jpg...
|
PhpStorm
|
faVsco.js – PlanhatService.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.82413566,"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":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.8394282,"top":0.019952115,"width":0.076130316,"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 'HandleHubspotRateLimitTest'","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 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"bounds":{"left":0.3882979,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.39993352,"top":0.07581804,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.4112367,"top":0.074221864,"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.41855052,"top":0.074221864,"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;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","depth":4,"bounds":{"left":0.122340426,"top":0.0726257,"width":0.30319148,"height":0.9273743},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.7124335,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.7237367,"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.73105055,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.44481382,"top":0.09736632,"width":0.55518615,"height":0.8818835},"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
8167272953559082915
|
-7714420151459628056
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
22859
|
976
|
73
|
2026-05-12T07:23:50.468668+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-12/1778 /Users/lukas/.screenpipe/data/data/2026-05-12/1778570630468_m1.jpg...
|
PhpStorm
|
faVsco.js – PlanhatService.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse GuzzleHttp\\Exception\\GuzzleException;\nuse Illuminate\\Contracts\\Container\\BindingResolutionException;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Jiminny\\Component\\BillingManagement\\Repositories\\RoleStatsRepository;\nuse Jiminny\\Models\\Partner;\nuse Jiminny\\Models\\Team;\nuse Jiminny\\Models\\User;\n\nreadonly class PlanhatService\n{\n public function __construct(\n private RoleStatsRepository $roleStatsRepository,\n ) {\n }\n\n /** @throws GuzzleException */\n public function track(User $user, string $event, array $payload = []): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'name' => $user->getName(),\n 'email' => $user->getEmailAddress(),\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->getTeam()->getUuid(),\n 'action' => $event,\n 'info' => $payload,\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('analytics/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, [\n 'body' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $data,\n ]);\n }\n\n /** @throws GuzzleException */\n public function meter(User $user, string $dimension, string $value): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $user->load('team');\n\n $data = [\n 'dimensionId' => $dimension,\n 'value' => $value,\n 'companyExternalId' => $user->getTeam()->getUuid(),\n ];\n\n $planhatResponse = Http::planhatAnalyticsApi()\n ->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function upsertCompany(Team $team): void\n {\n if (! $this->serviceIsAvailable($team->getPartnerId())) {\n return;\n }\n\n $integrations = $team->activityProviders()\n ->where('is_enabled', true)\n ->pluck('provider')\n ->toArray();\n\n $usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());\n $teamDomains = $team->domains()->pluck('domain')->toArray();\n\n $data = [\n 'externalId' => $team->getUuid(),\n 'sourceId' => $team->account?->crm_provider_id,\n 'name' => $team->getName(),\n 'slug' => $team->getSlug(),\n 'domains' => $teamDomains,\n 'custom' => [\n 'Conference decoupled?' => true,\n 'Email Provider' => $team->calendar_provider,\n 'CRM' => $team->crm?->provider,\n 'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',\n 'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',\n 'Integrations' => $integrations,\n 'Collaboration' => $team->hasSlackBot() ? 'slack' : null,\n 'Jiminny Voice Compliance mode' => $team->compliance_mode,\n 'Active users' => $usersRolesLookUp['active'],\n 'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],\n 'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],\n 'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],\n 'Data Center' => config('jiminny.deploy_region'),\n 'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,\n 'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('companies', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /**\n * @throws GuzzleException\n * @throws BindingResolutionException\n */\n public function upsertUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $intercomService = app()->make(IntercomService::class);\n\n $integrations = $user->socialAccounts()->pluck('provider')->toArray();\n $lastSeen = null;\n\n try {\n $intercomUser = $intercomService->getUsers([\n 'user_id' => $user->getUuid(),\n ]);\n\n if ($intercomUser) {\n $lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();\n }\n } catch (Exception $e) {\n Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);\n }\n\n $roleNames = $user->roles()->pluck('name');\n\n $data = [\n 'externalId' => $user->getUuid(),\n 'companyExternalId' => $user->team->getUuid(),\n 'name' => $user->getName(),\n 'position' => $user->job?->name,\n 'email' => $user->getEmailAddress(),\n 'createDate' => $user->created_at->toIso8601String(),\n 'custom' => [\n 'Role' => $roleNames->implode(', '),\n 'Group' => $user->group?->name,\n 'User Integrations' => $integrations,\n 'Licence Type' => $this->getLicenseType($roleNames),\n 'Jiminny Create Date' => $user->created_at->toIso8601String(),\n 'CRM Access' => $user->crm_required,\n 'Last seen' => $lastSeen,\n 'Email Synced' => $user->isSyncEmailEnabled(),\n 'User Job Title' => $user->job?->name ?? 'N/A',\n ],\n ];\n\n $planhatResponse = Http::planhatApi()->put('endusers', $data);\n\n $this->logFailedResponses($planhatResponse, __METHOD__, $data);\n }\n\n /** @throws GuzzleException */\n public function deleteUser(User $user): void\n {\n if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {\n return;\n }\n\n $planhatUserId = Http::planhatApi()\n ->get('endusers', ['email' => $user->email,])\n ->json('0._id');\n\n if ($planhatUserId) {\n Http::planhatApi()->delete(\"endusers/$planhatUserId\");\n\n Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);\n } else {\n Log::error(__METHOD__, ['result' => 'User not found in Planhat']);\n }\n }\n\n private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void\n {\n if (\n $planhatResponse->failed()\n || (\n isset($planhatResponse->json()['errors'])\n && ! empty($planhatResponse->json()['errors'])\n )\n ) {\n Log::error($message, [\n 'response' => $planhatResponse->json(),\n 'status' => $planhatResponse->status(),\n 'data' => $logData,\n ]);\n }\n }\n\n /**\n * Disable Planhat service on development and staging environments to prevent\n * failures and error noise in the logs.\n * It should run only for Jiminny partners\n */\n private function serviceIsAvailable(int $partnerId): bool\n {\n return config('services.planhat.enabled')\n && $partnerId === Partner::PARTNER_DEFAULT;\n }\n\n private function getLicenseType(Collection $roleNames): string\n {\n if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {\n return User::ROLE_RECORDER_AND_VOICE;\n }\n\n if ($roleNames->contains(User::ROLE_RECORDER)) {\n return User::ROLE_RECORDER;\n }\n\n return User::ROLE_ANALYST;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
378774241294272570
|
-7714420151459628056
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
12
19
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services;
use Carbon\Carbon;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Jiminny\Component\BillingManagement\Repositories\RoleStatsRepository;
use Jiminny\Models\Partner;
use Jiminny\Models\Team;
use Jiminny\Models\User;
readonly class PlanhatService
{
public function __construct(
private RoleStatsRepository $roleStatsRepository,
) {
}
/** @throws GuzzleException */
public function track(User $user, string $event, array $payload = []): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'name' => $user->getName(),
'email' => $user->getEmailAddress(),
'externalId' => $user->getUuid(),
'companyExternalId' => $user->getTeam()->getUuid(),
'action' => $event,
'info' => $payload,
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('analytics/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, [
'body' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $data,
]);
}
/** @throws GuzzleException */
public function meter(User $user, string $dimension, string $value): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$user->load('team');
$data = [
'dimensionId' => $dimension,
'value' => $value,
'companyExternalId' => $user->getTeam()->getUuid(),
];
$planhatResponse = Http::planhatAnalyticsApi()
->post('dimensiondata/' . config('services.planhat.tenantUuid'), $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function upsertCompany(Team $team): void
{
if (! $this->serviceIsAvailable($team->getPartnerId())) {
return;
}
$integrations = $team->activityProviders()
->where('is_enabled', true)
->pluck('provider')
->toArray();
$usersRolesLookUp = $this->roleStatsRepository->getPaidRolesLookup($team->getId());
$teamDomains = $team->domains()->pluck('domain')->toArray();
$data = [
'externalId' => $team->getUuid(),
'sourceId' => $team->account?->crm_provider_id,
'name' => $team->getName(),
'slug' => $team->getSlug(),
'domains' => $teamDomains,
'custom' => [
'Conference decoupled?' => true,
'Email Provider' => $team->calendar_provider,
'CRM' => $team->crm?->provider,
'Customer Api' => $team->getApiToken() === null ? 'no' : 'yes',
'Jiminny Voice' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE] > 0 ? 'Inbound + SMS' : 'Outbound Only',
'Integrations' => $integrations,
'Collaboration' => $team->hasSlackBot() ? 'slack' : null,
'Jiminny Voice Compliance mode' => $team->compliance_mode,
'Active users' => $usersRolesLookUp['active'],
'Recording users' => $usersRolesLookUp[User::ROLE_RECORDER],
'Voice users' => $usersRolesLookUp[User::ROLE_RECORDER_AND_VOICE],
'Listener users' => $usersRolesLookUp[User::ROLE_LISTENER],
'Data Center' => config('jiminny.deploy_region'),
'Active Jiminny Instance' => $team->status === Team::STATUS_ACTIVE,
'CRM Installed App Version' => $team->getCrmConfiguration()->getInstalledAppVersion(),
],
];
$planhatResponse = Http::planhatApi()->put('companies', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/**
* @throws GuzzleException
* @throws BindingResolutionException
*/
public function upsertUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$intercomService = app()->make(IntercomService::class);
$integrations = $user->socialAccounts()->pluck('provider')->toArray();
$lastSeen = null;
try {
$intercomUser = $intercomService->getUsers([
'user_id' => $user->getUuid(),
]);
if ($intercomUser) {
$lastSeen = Carbon::parse($intercomUser->last_request_at)->toIso8601String();
}
} catch (Exception $e) {
Log::error(__METHOD__ . ' Intercom failed to fetch user data', ['error' => $e->getMessage()]);
}
$roleNames = $user->roles()->pluck('name');
$data = [
'externalId' => $user->getUuid(),
'companyExternalId' => $user->team->getUuid(),
'name' => $user->getName(),
'position' => $user->job?->name,
'email' => $user->getEmailAddress(),
'createDate' => $user->created_at->toIso8601String(),
'custom' => [
'Role' => $roleNames->implode(', '),
'Group' => $user->group?->name,
'User Integrations' => $integrations,
'Licence Type' => $this->getLicenseType($roleNames),
'Jiminny Create Date' => $user->created_at->toIso8601String(),
'CRM Access' => $user->crm_required,
'Last seen' => $lastSeen,
'Email Synced' => $user->isSyncEmailEnabled(),
'User Job Title' => $user->job?->name ?? 'N/A',
],
];
$planhatResponse = Http::planhatApi()->put('endusers', $data);
$this->logFailedResponses($planhatResponse, __METHOD__, $data);
}
/** @throws GuzzleException */
public function deleteUser(User $user): void
{
if (! $this->serviceIsAvailable($user->getTeam()->getPartnerId())) {
return;
}
$planhatUserId = Http::planhatApi()
->get('endusers', ['email' => $user->email,])
->json('0._id');
if ($planhatUserId) {
Http::planhatApi()->delete("endusers/$planhatUserId");
Log::info(__METHOD__, ['result' => 'User deleted from Planhat']);
} else {
Log::error(__METHOD__, ['result' => 'User not found in Planhat']);
}
}
private function logFailedResponses(Response $planhatResponse, string $message, array $logData): void
{
if (
$planhatResponse->failed()
|| (
isset($planhatResponse->json()['errors'])
&& ! empty($planhatResponse->json()['errors'])
)
) {
Log::error($message, [
'response' => $planhatResponse->json(),
'status' => $planhatResponse->status(),
'data' => $logData,
]);
}
}
/**
* Disable Planhat service on development and staging environments to prevent
* failures and error noise in the logs.
* It should run only for Jiminny partners
*/
private function serviceIsAvailable(int $partnerId): bool
{
return config('services.planhat.enabled')
&& $partnerId === Partner::PARTNER_DEFAULT;
}
private function getLicenseType(Collection $roleNames): string
{
if ($roleNames->contains(User::ROLE_RECORDER_AND_VOICE)) {
return User::ROLE_RECORDER_AND_VOICE;
}
if ($roleNames->contains(User::ROLE_RECORDER)) {
return User::ROLE_RECORDER;
}
return User::ROLE_ANALYST;
}
}
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Project
Project
New File or Directory…
Expand Selected...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45507
|
1627
|
35
|
2026-05-14T14:40:30.545459+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769630545_m1.jpg...
|
PhpStorm
|
faVsco.js – PlainTextDecorateActivity.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\DecorateActivity;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Jiminny\Models\Activity;
use Jiminny\Repositories\ActivityMessageRepository;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Utils\PlaybackUrlBuilder;
class PlainTextDecorateActivity extends BaseDecorateActivity
{
private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;
public function generateTitle(Activity $activity): ?string
{
$title = null;
if ($activity->hasTitle()) {
$title = $activity->getTitle();
} elseif ($activity->hasActivityType()) {
$title = $activity->getActivityType()->getName();
}
switch ($activity->getCrmType()) {
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$title = Str::limit($activity->getDescription(), 250);
break;
case Activity::TYPE_SOFTPHONE_INBOUND:
if ($title === null) {
$title = 'Inbound Call';
}
break;
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_CONFERENCE:
default:
if ($title === null) {
$title = 'Outbound Call';
}
break;
}
return $title;
}
public function generateDescription(Activity $activity): ?string
{
$description = '';
switch ($activity->getCrmType()) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$description = $this->getCallHeading($activity);
if ($activity->isTypeSoftphoneInbound()) {
$description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;
} else {
$description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;
}
$description .= $this->getPreviewUrl($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_CONFERENCE:
$description = $this->getCallHeading($activity);
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' .
self::SECTION_SEPARATOR;
} else {
$description .= $this->getPreviewUrl($activity);
}
$description .= self::SECTION_ATTENDEES . ':'
. PHP_EOL
. app(FilterJoinedParticipants::class)->toString($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
default:
if (mb_strlen($activity->getDescription() ?? '') > 250) {
$description = $activity->getDescription();
}
break;
}
return $description;
}
public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string
{
if (empty($existingCrmDescription)) {
return $jiminnyDescription;
}
if (! $this->isJiminnyDescription($existingCrmDescription)) {
return $existingCrmDescription
. self::SECTION_SEPARATOR
. str_repeat('-', 50)
. PHP_EOL
. $jiminnyDescription;
}
$existingSections = $this->parseSections($existingCrmDescription);
$newSections = $this->parseSections($jiminnyDescription);
return $this->mergeSections($newSections, $existingSections);
}
private function getCallHeading(Activity $activity): string
{
$description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;
$description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;
return $description;
}
private function getPreviewUrl(Activity $activity): string
{
$description = '';
if ($activity->canReviewActivity()) {
$playbackUrl = PlaybackUrlBuilder::build($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;
}
return $description;
}
private function getActivityNotes(Activity $activity): string
{
if ($activity->getNotes()->isEmpty()) {
return '';
}
$description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;
foreach ($activity->getNotes() as $note) {
$time = ($note->getTime() > 3600)
? gmdate('H:i:s', (int) $note->getTime())
: gmdate('i:s', (int) $note->getTime());
$description .= $time . ' ' . $note->getNote() . PHP_EOL;
}
return $description;
}
private function getActivityMessages(Activity $activity): string
{
return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);
}
private function getCoachingChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$privateMessages = $messageRepo->getPrivateMessages($activity);
if ($privateMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($privateMessages);
}
private function getCustomerChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$publicMessages = $messageRepo->getPublicMessages($activity);
if ($publicMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($publicMessages);
}
private function getMessagesAsString(Collection $messages): string
{
$description = '';
/** @var Activity\Message $message */
foreach ($messages as $message) {
$description .= $message->participant->getName()
. ': '
. $message->getAttribute('message')
. PHP_EOL;
}
return $description;
}
private function getSummaryField(Activity $activity): string
{
if ($activity->getSummary() === null) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();
}
private function parseSections(string $description): array
{
$sections = [
'Original Content' => '',
'Header' => '',
];
foreach (self::SECTION_HEADERS as $sectionName) {
$sections[$sectionName] = '';
}
$markerPos = strpos($description, self::JIMINNY_MARKER);
if ($markerPos !== false && $markerPos > 0) {
$sections['Original Content'] = trim(substr($description, 0, $markerPos));
}
$jiminnyStart = $markerPos !== false ? $markerPos : 0;
$headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);
if ($headerEndPos === false) {
$sections['Header'] = trim(substr($description, $jiminnyStart));
return $sections;
}
$sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));
foreach (self::SECTION_HEADERS as $sectionName) {
$sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);
if ($sectionContent !== null) {
$sections[$sectionName] = $sectionContent;
}
}
return $sections;
}
private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false
{
$firstPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {
$firstPos = $pos;
}
}
return $firstPos;
}
private function extractSectionContent(
string $description,
string $sectionName,
int $afterPosition = 0
): ?string {
$sectionStart = strpos($description, $sectionName . ':', $afterPosition);
if ($sectionStart === false) {
return null;
}
$contentStart = $sectionStart + strlen($sectionName . ':');
$nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);
$endPos = strlen($description);
if ($nextSectionPos !== false) {
$endPos = $nextSectionPos;
}
return trim(substr($description, $contentStart, $endPos - $contentStart));
}
private function findNextSectionPosition(
string $description,
int $afterPosition,
string $currentSection = ''
): int|false {
$nextPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
if ($sectionName === $currentSection) {
continue;
}
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {
$nextPos = $pos;
}
}
return $nextPos;
}
private function mergeSections(array $newSections, array $existingSections): string
{
$result = '';
$originalContent = $existingSections['Original Content'] ?? '';
if (! empty($originalContent)) {
$result .= $originalContent . self::SECTION_SEPARATOR;
}
$result .= $newSections['Header'];
foreach (self::SECTION_HEADERS as $sectionName) {
$newContent = $newSections[$sectionName] ?? '';
$existingContent = $existingSections[$sectionName] ?? '';
$mergedContent = $this->mergeSectionContent($newContent, $existingContent);
if (! empty($mergedContent)) {
$result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;
}
}
return $result;
}
private function mergeSectionContent(string $newContent, string $existingContent): string
{
if (! empty($existingContent)) {
return $existingContent;
}
return $newContent;
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
3
4
Previous Highlighted Error
Next Highlighted Error
SELECT
# DISTINCT
opp.id as opp_id, opp.uuid, opp.name,
# COUNT(dr.id) AS deal_risk_count,
# dr.id,
# cfd.value,
usr.name AS owner_name,
opp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,
usr.uuid as owner_uuid,
usr.photo_path as owner_photo,
usr.id AS owner_id,
jt.name as owner_job,
opp.stage_id, opp.stage_updated_at,
acc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,
rt.business_process_id AS pipeline_id
FROM opportunities opp
LEFT JOIN record_types rt ON opp.record_type_id = rt.id
LEFT JOIN users usr ON opp.user_id = usr.id
LEFT JOIN accounts acc ON opp.account_id = acc.id
LEFT JOIN job_titles jt ON usr.job_title_id = jt.id
# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id
# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id
LEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)
WHERE opp.user_id IS NOT NULL
AND opp.deleted_at IS NULL
AND opp.is_closed = 1
AND opp.is_won = 0
AND opp.close_date >= '2024-01-01 00:00:00'
AND opp.close_date <= '2024-12-31 23:59:59'
AND usr.team_id = 1
# and opp.id = 4823179
GROUP BY opp.id, cfd.value
ORDER BY
# cfd.value
CAST(cfd.value AS UNSIGNED)
# owner_name
# (SELECT COUNT(dr.id)
# FROM deal_risks dr
# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
# WHERE dr.opportunity_id = opp.id
# AND dr.is_active = 1
# AND gdrt.is_enabled = 1)
DESC
LIMIT 25
# OFFSET 0
;
select * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';
select * from crm_layout_entities where crm_layout_id = 1493;
SELECT * FROM opportunities WHERE id = 4584045;
SELECT * FROM crm_field_data WHERE object_id = 4584045;
SELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'
SELECT
opp.id as opportunity_id,
opp.name,
COUNT(dr.id) as risk_count
FROM opportunities opp
LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id
WHERE opp.id IN (
6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788
)
GROUP BY opp.id;
SELECT COUNT(dr.id)
FROM deal_risks dr
JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
WHERE dr.opportunity_id = 5563344
# AND gdrt.group_id = usr.group_id
EXPLAIN SELECT
opp.id as opp_id, opp.uuid, opp.name,
cfd.value,
cfv.sequence,
cfv.value,
# MAX(cfd.value) AS max_cfd_value,
usr.name AS owner_name,
opp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,
usr.uuid as owner_uuid,
usr.photo_path as owner_photo,
usr.id AS owner_id,
jt.name as owner_job,
opp.stage_id, opp.stage_updated_at,
acc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,
rt.business_process_id AS pipeline_id
FROM opportunities opp
LEFT JOIN record_types rt ON opp.record_type_id = rt.id
LEFT JOIN users usr ON opp.user_id = usr.id
LEFT JOIN accounts acc ON opp.account_id = acc.id
LEFT JOIN job_titles jt ON usr.job_title_id = jt.id
LEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id
FROM crm_field_data sub_cfd
WHERE sub_cfd.object_id = opp.id
AND sub_cfd.crm_field_id = 66810
ORDER BY sub_cfd.updated_at DESC
LIMIT 1)
LEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value
WHERE opp.user_id IS NOT NULL
AND opp.deleted_at IS NULL
AND opp.is_closed = 0
# AND opp.is_closed = 1
# AND opp.is_won = 0
AND opp.close_date >= '2024-01-01 00:00:00'
AND opp.close_date <= '2024-12-31 23:59:59'
AND usr.team_id = 1
# and opp.id = 4823179
# GROUP BY opp.id
ORDER BY
cfv.sequence DESC,
# opp.name
# owner_name
cfd.value
# CAST(cfd.value AS UNSIGNED)
# CAST(MAX(cfd.value) AS UNSIGNED)
# (SELECT COUNT(dr.id)
# FROM deal_risks dr
# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
# WHERE dr.opportunity_id = opp.id
# AND dr.is_active = 1 AND gdrt.is_enabled = 1)
DESC
# LIMIT 75, 25
;
SELECT * FROM crm_field_values WHERE crm_field_id = 66810;
SELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at
FROM crm_fields f
INNER JOIN crm_field_data fd ON fd.crm_field_id = f.id
WHERE (f.crm_configuration_id = 1)
AND (f.object_type = 'opportunity')
# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))
AND (fd.object_id IN (4158460))
AND (f.crm_provider_id IN ('ForecastCategoryName'))
ORDER BY fd.object_id ASC, fd.updated_at DESC;
select * from crm_layouts where crm_configuration_id = 1;
select cf.* from crm_fields cf
join crm_layout_entities cle on cf.id = cle.crm_field_id
where crm_layout_id = 1493;
SELECT * FROM opportunities WHERE id = 4158460;
SELECT * FROM crm_field_data WHERE object_id = 4158460;
select * from users where team_id = 1;
SELECT * FROM users WHERE id = 7160;
select * from role_user where user_id = 3248;
select * from crm_field_values where crm_field_id = 66824;
SELECT * FROM users WHERE id = 23470;
select * from crm_configurations;
# [PASSWORD_DOTS]
select * from teams where id = 1038; # 23521, 966
select * from users where team_id = 1038;
select * from crm_configurations where id = 966;
SELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762
SELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764
SELECT * FROM participants WHERE activity_id = 54965764;
SELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075
SELECT * FROM participants WHERE activity_id = 54964075;
select f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id
where tf.team_id = 1038;
# [PASSWORD_DOTS] PD [PASSWORD_DOTS]
select * from teams where id = 1029;
SELECT * FROM crm_configurations WHERE id = 957;
SELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423
SELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433
SELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421
# [PASSWORD_DOTS] Close [PASSWORD_DOTS]
select * from teams where id = 1031;
SELECT * FROM crm_configurations WHERE id = 959;
SELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517
SELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433
SELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009
# [PASSWORD_DOTS] SF [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;
select * from roles;
select * from permissions;
select * from permission_role where permission_id = 136;
select * from migrations order by id desc;
select * from teams where id IN (1, 1037);
select * from crm_layouts where crm_configuration_id = 1;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;
SELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);
select * from features;
select * from team_features where feature_id = 33;
select * from opportunities;
select * from teams;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1052 and sa.provider = 'hubspot';
SELECT * FROM accounts where id = 11512582;
select * from activities where account_id = 11512582;
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.78194445,"top":0.3122222,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\DecorateActivity;\n\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Repositories\\ActivityMessageRepository;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\n\nclass PlainTextDecorateActivity extends BaseDecorateActivity\n{\n private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;\n\n public function generateTitle(Activity $activity): ?string\n {\n $title = null;\n if ($activity->hasTitle()) {\n $title = $activity->getTitle();\n } elseif ($activity->hasActivityType()) {\n $title = $activity->getActivityType()->getName();\n }\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $title = Str::limit($activity->getDescription(), 250);\n\n break;\n\n case Activity::TYPE_SOFTPHONE_INBOUND:\n if ($title === null) {\n $title = 'Inbound Call';\n }\n\n break;\n\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_CONFERENCE:\n default:\n if ($title === null) {\n $title = 'Outbound Call';\n }\n\n break;\n }\n\n return $title;\n }\n\n public function generateDescription(Activity $activity): ?string\n {\n $description = '';\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $description = $this->getCallHeading($activity);\n\n if ($activity->isTypeSoftphoneInbound()) {\n $description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;\n } else {\n $description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;\n }\n\n $description .= $this->getPreviewUrl($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_CONFERENCE:\n $description = $this->getCallHeading($activity);\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' .\n self::SECTION_SEPARATOR;\n } else {\n $description .= $this->getPreviewUrl($activity);\n }\n\n $description .= self::SECTION_ATTENDEES . ':'\n . PHP_EOL\n . app(FilterJoinedParticipants::class)->toString($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n default:\n if (mb_strlen($activity->getDescription() ?? '') > 250) {\n $description = $activity->getDescription();\n }\n\n break;\n }\n\n return $description;\n }\n\n public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string\n {\n if (empty($existingCrmDescription)) {\n return $jiminnyDescription;\n }\n\n if (! $this->isJiminnyDescription($existingCrmDescription)) {\n return $existingCrmDescription\n . self::SECTION_SEPARATOR\n . str_repeat('-', 50)\n . PHP_EOL\n . $jiminnyDescription;\n }\n\n $existingSections = $this->parseSections($existingCrmDescription);\n $newSections = $this->parseSections($jiminnyDescription);\n\n return $this->mergeSections($newSections, $existingSections);\n }\n\n private function getCallHeading(Activity $activity): string\n {\n $description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;\n $description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;\n\n return $description;\n }\n\n private function getPreviewUrl(Activity $activity): string\n {\n $description = '';\n if ($activity->canReviewActivity()) {\n $playbackUrl = PlaybackUrlBuilder::build($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;\n }\n\n return $description;\n }\n\n private function getActivityNotes(Activity $activity): string\n {\n if ($activity->getNotes()->isEmpty()) {\n return '';\n }\n\n $description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;\n\n foreach ($activity->getNotes() as $note) {\n $time = ($note->getTime() > 3600)\n ? gmdate('H:i:s', (int) $note->getTime())\n : gmdate('i:s', (int) $note->getTime());\n\n $description .= $time . ' ' . $note->getNote() . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getActivityMessages(Activity $activity): string\n {\n return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);\n }\n\n private function getCoachingChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $privateMessages = $messageRepo->getPrivateMessages($activity);\n\n if ($privateMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($privateMessages);\n }\n\n private function getCustomerChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $publicMessages = $messageRepo->getPublicMessages($activity);\n\n if ($publicMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($publicMessages);\n }\n\n private function getMessagesAsString(Collection $messages): string\n {\n $description = '';\n\n /** @var Activity\\Message $message */\n foreach ($messages as $message) {\n $description .= $message->participant->getName()\n . ': '\n . $message->getAttribute('message')\n . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getSummaryField(Activity $activity): string\n {\n if ($activity->getSummary() === null) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();\n }\n\n private function parseSections(string $description): array\n {\n $sections = [\n 'Original Content' => '',\n 'Header' => '',\n ];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sections[$sectionName] = '';\n }\n\n $markerPos = strpos($description, self::JIMINNY_MARKER);\n if ($markerPos !== false && $markerPos > 0) {\n $sections['Original Content'] = trim(substr($description, 0, $markerPos));\n }\n\n $jiminnyStart = $markerPos !== false ? $markerPos : 0;\n $headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);\n\n if ($headerEndPos === false) {\n $sections['Header'] = trim(substr($description, $jiminnyStart));\n\n return $sections;\n }\n\n $sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);\n if ($sectionContent !== null) {\n $sections[$sectionName] = $sectionContent;\n }\n }\n\n return $sections;\n }\n\n private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false\n {\n $firstPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {\n $firstPos = $pos;\n }\n }\n\n return $firstPos;\n }\n\n private function extractSectionContent(\n string $description,\n string $sectionName,\n int $afterPosition = 0\n ): ?string {\n $sectionStart = strpos($description, $sectionName . ':', $afterPosition);\n if ($sectionStart === false) {\n return null;\n }\n\n $contentStart = $sectionStart + strlen($sectionName . ':');\n $nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);\n\n $endPos = strlen($description);\n if ($nextSectionPos !== false) {\n $endPos = $nextSectionPos;\n }\n\n return trim(substr($description, $contentStart, $endPos - $contentStart));\n }\n\n private function findNextSectionPosition(\n string $description,\n int $afterPosition,\n string $currentSection = ''\n ): int|false {\n $nextPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n if ($sectionName === $currentSection) {\n continue;\n }\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {\n $nextPos = $pos;\n }\n }\n\n return $nextPos;\n }\n\n private function mergeSections(array $newSections, array $existingSections): string\n {\n $result = '';\n\n $originalContent = $existingSections['Original Content'] ?? '';\n if (! empty($originalContent)) {\n $result .= $originalContent . self::SECTION_SEPARATOR;\n }\n\n $result .= $newSections['Header'];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $newContent = $newSections[$sectionName] ?? '';\n $existingContent = $existingSections[$sectionName] ?? '';\n\n $mergedContent = $this->mergeSectionContent($newContent, $existingContent);\n if (! empty($mergedContent)) {\n $result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;\n }\n }\n\n return $result;\n }\n\n private function mergeSectionContent(string $newContent, string $existingContent): string\n {\n if (! empty($existingContent)) {\n return $existingContent;\n }\n\n return $newContent;\n }\n}","depth":4,"bounds":{"left":0.26666668,"top":0.21111111,"width":0.6166667,"height":0.7888889},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\DecorateActivity;\n\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Repositories\\ActivityMessageRepository;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\n\nclass PlainTextDecorateActivity extends BaseDecorateActivity\n{\n private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;\n\n public function generateTitle(Activity $activity): ?string\n {\n $title = null;\n if ($activity->hasTitle()) {\n $title = $activity->getTitle();\n } elseif ($activity->hasActivityType()) {\n $title = $activity->getActivityType()->getName();\n }\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $title = Str::limit($activity->getDescription(), 250);\n\n break;\n\n case Activity::TYPE_SOFTPHONE_INBOUND:\n if ($title === null) {\n $title = 'Inbound Call';\n }\n\n break;\n\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_CONFERENCE:\n default:\n if ($title === null) {\n $title = 'Outbound Call';\n }\n\n break;\n }\n\n return $title;\n }\n\n public function generateDescription(Activity $activity): ?string\n {\n $description = '';\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $description = $this->getCallHeading($activity);\n\n if ($activity->isTypeSoftphoneInbound()) {\n $description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;\n } else {\n $description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;\n }\n\n $description .= $this->getPreviewUrl($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_CONFERENCE:\n $description = $this->getCallHeading($activity);\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' .\n self::SECTION_SEPARATOR;\n } else {\n $description .= $this->getPreviewUrl($activity);\n }\n\n $description .= self::SECTION_ATTENDEES . ':'\n . PHP_EOL\n . app(FilterJoinedParticipants::class)->toString($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n default:\n if (mb_strlen($activity->getDescription() ?? '') > 250) {\n $description = $activity->getDescription();\n }\n\n break;\n }\n\n return $description;\n }\n\n public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string\n {\n if (empty($existingCrmDescription)) {\n return $jiminnyDescription;\n }\n\n if (! $this->isJiminnyDescription($existingCrmDescription)) {\n return $existingCrmDescription\n . self::SECTION_SEPARATOR\n . str_repeat('-', 50)\n . PHP_EOL\n . $jiminnyDescription;\n }\n\n $existingSections = $this->parseSections($existingCrmDescription);\n $newSections = $this->parseSections($jiminnyDescription);\n\n return $this->mergeSections($newSections, $existingSections);\n }\n\n private function getCallHeading(Activity $activity): string\n {\n $description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;\n $description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;\n\n return $description;\n }\n\n private function getPreviewUrl(Activity $activity): string\n {\n $description = '';\n if ($activity->canReviewActivity()) {\n $playbackUrl = PlaybackUrlBuilder::build($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;\n }\n\n return $description;\n }\n\n private function getActivityNotes(Activity $activity): string\n {\n if ($activity->getNotes()->isEmpty()) {\n return '';\n }\n\n $description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;\n\n foreach ($activity->getNotes() as $note) {\n $time = ($note->getTime() > 3600)\n ? gmdate('H:i:s', (int) $note->getTime())\n : gmdate('i:s', (int) $note->getTime());\n\n $description .= $time . ' ' . $note->getNote() . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getActivityMessages(Activity $activity): string\n {\n return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);\n }\n\n private function getCoachingChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $privateMessages = $messageRepo->getPrivateMessages($activity);\n\n if ($privateMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($privateMessages);\n }\n\n private function getCustomerChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $publicMessages = $messageRepo->getPublicMessages($activity);\n\n if ($publicMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($publicMessages);\n }\n\n private function getMessagesAsString(Collection $messages): string\n {\n $description = '';\n\n /** @var Activity\\Message $message */\n foreach ($messages as $message) {\n $description .= $message->participant->getName()\n . ': '\n . $message->getAttribute('message')\n . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getSummaryField(Activity $activity): string\n {\n if ($activity->getSummary() === null) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();\n }\n\n private function parseSections(string $description): array\n {\n $sections = [\n 'Original Content' => '',\n 'Header' => '',\n ];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sections[$sectionName] = '';\n }\n\n $markerPos = strpos($description, self::JIMINNY_MARKER);\n if ($markerPos !== false && $markerPos > 0) {\n $sections['Original Content'] = trim(substr($description, 0, $markerPos));\n }\n\n $jiminnyStart = $markerPos !== false ? $markerPos : 0;\n $headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);\n\n if ($headerEndPos === false) {\n $sections['Header'] = trim(substr($description, $jiminnyStart));\n\n return $sections;\n }\n\n $sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);\n if ($sectionContent !== null) {\n $sections[$sectionName] = $sectionContent;\n }\n }\n\n return $sections;\n }\n\n private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false\n {\n $firstPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {\n $firstPos = $pos;\n }\n }\n\n return $firstPos;\n }\n\n private function extractSectionContent(\n string $description,\n string $sectionName,\n int $afterPosition = 0\n ): ?string {\n $sectionStart = strpos($description, $sectionName . ':', $afterPosition);\n if ($sectionStart === false) {\n return null;\n }\n\n $contentStart = $sectionStart + strlen($sectionName . ':');\n $nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);\n\n $endPos = strlen($description);\n if ($nextSectionPos !== false) {\n $endPos = $nextSectionPos;\n }\n\n return trim(substr($description, $contentStart, $endPos - $contentStart));\n }\n\n private function findNextSectionPosition(\n string $description,\n int $afterPosition,\n string $currentSection = ''\n ): int|false {\n $nextPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n if ($sectionName === $currentSection) {\n continue;\n }\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {\n $nextPos = $pos;\n }\n }\n\n return $nextPos;\n }\n\n private function mergeSections(array $newSections, array $existingSections): string\n {\n $result = '';\n\n $originalContent = $existingSections['Original Content'] ?? '';\n if (! empty($originalContent)) {\n $result .= $originalContent . self::SECTION_SEPARATOR;\n }\n\n $result .= $newSections['Header'];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $newContent = $newSections[$sectionName] ?? '';\n $existingContent = $existingSections[$sectionName] ?? '';\n\n $mergedContent = $this->mergeSectionContent($newContent, $existingContent);\n if (! empty($mergedContent)) {\n $result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;\n }\n }\n\n return $result;\n }\n\n private function mergeSectionContent(string $newContent, string $existingContent): string\n {\n if (! empty($existingContent)) {\n return $existingContent;\n }\n\n return $newContent;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
5189083665197206925
|
2074642764900996701
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\DecorateActivity;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Jiminny\Models\Activity;
use Jiminny\Repositories\ActivityMessageRepository;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Utils\PlaybackUrlBuilder;
class PlainTextDecorateActivity extends BaseDecorateActivity
{
private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;
public function generateTitle(Activity $activity): ?string
{
$title = null;
if ($activity->hasTitle()) {
$title = $activity->getTitle();
} elseif ($activity->hasActivityType()) {
$title = $activity->getActivityType()->getName();
}
switch ($activity->getCrmType()) {
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$title = Str::limit($activity->getDescription(), 250);
break;
case Activity::TYPE_SOFTPHONE_INBOUND:
if ($title === null) {
$title = 'Inbound Call';
}
break;
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_CONFERENCE:
default:
if ($title === null) {
$title = 'Outbound Call';
}
break;
}
return $title;
}
public function generateDescription(Activity $activity): ?string
{
$description = '';
switch ($activity->getCrmType()) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$description = $this->getCallHeading($activity);
if ($activity->isTypeSoftphoneInbound()) {
$description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;
} else {
$description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;
}
$description .= $this->getPreviewUrl($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_CONFERENCE:
$description = $this->getCallHeading($activity);
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' .
self::SECTION_SEPARATOR;
} else {
$description .= $this->getPreviewUrl($activity);
}
$description .= self::SECTION_ATTENDEES . ':'
. PHP_EOL
. app(FilterJoinedParticipants::class)->toString($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
default:
if (mb_strlen($activity->getDescription() ?? '') > 250) {
$description = $activity->getDescription();
}
break;
}
return $description;
}
public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string
{
if (empty($existingCrmDescription)) {
return $jiminnyDescription;
}
if (! $this->isJiminnyDescription($existingCrmDescription)) {
return $existingCrmDescription
. self::SECTION_SEPARATOR
. str_repeat('-', 50)
. PHP_EOL
. $jiminnyDescription;
}
$existingSections = $this->parseSections($existingCrmDescription);
$newSections = $this->parseSections($jiminnyDescription);
return $this->mergeSections($newSections, $existingSections);
}
private function getCallHeading(Activity $activity): string
{
$description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;
$description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;
return $description;
}
private function getPreviewUrl(Activity $activity): string
{
$description = '';
if ($activity->canReviewActivity()) {
$playbackUrl = PlaybackUrlBuilder::build($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;
}
return $description;
}
private function getActivityNotes(Activity $activity): string
{
if ($activity->getNotes()->isEmpty()) {
return '';
}
$description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;
foreach ($activity->getNotes() as $note) {
$time = ($note->getTime() > 3600)
? gmdate('H:i:s', (int) $note->getTime())
: gmdate('i:s', (int) $note->getTime());
$description .= $time . ' ' . $note->getNote() . PHP_EOL;
}
return $description;
}
private function getActivityMessages(Activity $activity): string
{
return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);
}
private function getCoachingChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$privateMessages = $messageRepo->getPrivateMessages($activity);
if ($privateMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($privateMessages);
}
private function getCustomerChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$publicMessages = $messageRepo->getPublicMessages($activity);
if ($publicMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($publicMessages);
}
private function getMessagesAsString(Collection $messages): string
{
$description = '';
/** @var Activity\Message $message */
foreach ($messages as $message) {
$description .= $message->participant->getName()
. ': '
. $message->getAttribute('message')
. PHP_EOL;
}
return $description;
}
private function getSummaryField(Activity $activity): string
{
if ($activity->getSummary() === null) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();
}
private function parseSections(string $description): array
{
$sections = [
'Original Content' => '',
'Header' => '',
];
foreach (self::SECTION_HEADERS as $sectionName) {
$sections[$sectionName] = '';
}
$markerPos = strpos($description, self::JIMINNY_MARKER);
if ($markerPos !== false && $markerPos > 0) {
$sections['Original Content'] = trim(substr($description, 0, $markerPos));
}
$jiminnyStart = $markerPos !== false ? $markerPos : 0;
$headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);
if ($headerEndPos === false) {
$sections['Header'] = trim(substr($description, $jiminnyStart));
return $sections;
}
$sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));
foreach (self::SECTION_HEADERS as $sectionName) {
$sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);
if ($sectionContent !== null) {
$sections[$sectionName] = $sectionContent;
}
}
return $sections;
}
private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false
{
$firstPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {
$firstPos = $pos;
}
}
return $firstPos;
}
private function extractSectionContent(
string $description,
string $sectionName,
int $afterPosition = 0
): ?string {
$sectionStart = strpos($description, $sectionName . ':', $afterPosition);
if ($sectionStart === false) {
return null;
}
$contentStart = $sectionStart + strlen($sectionName . ':');
$nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);
$endPos = strlen($description);
if ($nextSectionPos !== false) {
$endPos = $nextSectionPos;
}
return trim(substr($description, $contentStart, $endPos - $contentStart));
}
private function findNextSectionPosition(
string $description,
int $afterPosition,
string $currentSection = ''
): int|false {
$nextPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
if ($sectionName === $currentSection) {
continue;
}
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {
$nextPos = $pos;
}
}
return $nextPos;
}
private function mergeSections(array $newSections, array $existingSections): string
{
$result = '';
$originalContent = $existingSections['Original Content'] ?? '';
if (! empty($originalContent)) {
$result .= $originalContent . self::SECTION_SEPARATOR;
}
$result .= $newSections['Header'];
foreach (self::SECTION_HEADERS as $sectionName) {
$newContent = $newSections[$sectionName] ?? '';
$existingContent = $existingSections[$sectionName] ?? '';
$mergedContent = $this->mergeSectionContent($newContent, $existingContent);
if (! empty($mergedContent)) {
$result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;
}
}
return $result;
}
private function mergeSectionContent(string $newContent, string $existingContent): string
{
if (! empty($existingContent)) {
return $existingContent;
}
return $newContent;
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
3
4
Previous Highlighted Error
Next Highlighted Error
SELECT
# DISTINCT
opp.id as opp_id, opp.uuid, opp.name,
# COUNT(dr.id) AS deal_risk_count,
# dr.id,
# cfd.value,
usr.name AS owner_name,
opp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,
usr.uuid as owner_uuid,
usr.photo_path as owner_photo,
usr.id AS owner_id,
jt.name as owner_job,
opp.stage_id, opp.stage_updated_at,
acc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,
rt.business_process_id AS pipeline_id
FROM opportunities opp
LEFT JOIN record_types rt ON opp.record_type_id = rt.id
LEFT JOIN users usr ON opp.user_id = usr.id
LEFT JOIN accounts acc ON opp.account_id = acc.id
LEFT JOIN job_titles jt ON usr.job_title_id = jt.id
# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id
# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id
LEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)
WHERE opp.user_id IS NOT NULL
AND opp.deleted_at IS NULL
AND opp.is_closed = 1
AND opp.is_won = 0
AND opp.close_date >= '2024-01-01 00:00:00'
AND opp.close_date <= '2024-12-31 23:59:59'
AND usr.team_id = 1
# and opp.id = 4823179
GROUP BY opp.id, cfd.value
ORDER BY
# cfd.value
CAST(cfd.value AS UNSIGNED)
# owner_name
# (SELECT COUNT(dr.id)
# FROM deal_risks dr
# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
# WHERE dr.opportunity_id = opp.id
# AND dr.is_active = 1
# AND gdrt.is_enabled = 1)
DESC
LIMIT 25
# OFFSET 0
;
select * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';
select * from crm_layout_entities where crm_layout_id = 1493;
SELECT * FROM opportunities WHERE id = 4584045;
SELECT * FROM crm_field_data WHERE object_id = 4584045;
SELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'
SELECT
opp.id as opportunity_id,
opp.name,
COUNT(dr.id) as risk_count
FROM opportunities opp
LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id
WHERE opp.id IN (
6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788
)
GROUP BY opp.id;
SELECT COUNT(dr.id)
FROM deal_risks dr
JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
WHERE dr.opportunity_id = 5563344
# AND gdrt.group_id = usr.group_id
EXPLAIN SELECT
opp.id as opp_id, opp.uuid, opp.name,
cfd.value,
cfv.sequence,
cfv.value,
# MAX(cfd.value) AS max_cfd_value,
usr.name AS owner_name,
opp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,
usr.uuid as owner_uuid,
usr.photo_path as owner_photo,
usr.id AS owner_id,
jt.name as owner_job,
opp.stage_id, opp.stage_updated_at,
acc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,
rt.business_process_id AS pipeline_id
FROM opportunities opp
LEFT JOIN record_types rt ON opp.record_type_id = rt.id
LEFT JOIN users usr ON opp.user_id = usr.id
LEFT JOIN accounts acc ON opp.account_id = acc.id
LEFT JOIN job_titles jt ON usr.job_title_id = jt.id
LEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id
FROM crm_field_data sub_cfd
WHERE sub_cfd.object_id = opp.id
AND sub_cfd.crm_field_id = 66810
ORDER BY sub_cfd.updated_at DESC
LIMIT 1)
LEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value
WHERE opp.user_id IS NOT NULL
AND opp.deleted_at IS NULL
AND opp.is_closed = 0
# AND opp.is_closed = 1
# AND opp.is_won = 0
AND opp.close_date >= '2024-01-01 00:00:00'
AND opp.close_date <= '2024-12-31 23:59:59'
AND usr.team_id = 1
# and opp.id = 4823179
# GROUP BY opp.id
ORDER BY
cfv.sequence DESC,
# opp.name
# owner_name
cfd.value
# CAST(cfd.value AS UNSIGNED)
# CAST(MAX(cfd.value) AS UNSIGNED)
# (SELECT COUNT(dr.id)
# FROM deal_risks dr
# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
# WHERE dr.opportunity_id = opp.id
# AND dr.is_active = 1 AND gdrt.is_enabled = 1)
DESC
# LIMIT 75, 25
;
SELECT * FROM crm_field_values WHERE crm_field_id = 66810;
SELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at
FROM crm_fields f
INNER JOIN crm_field_data fd ON fd.crm_field_id = f.id
WHERE (f.crm_configuration_id = 1)
AND (f.object_type = 'opportunity')
# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))
AND (fd.object_id IN (4158460))
AND (f.crm_provider_id IN ('ForecastCategoryName'))
ORDER BY fd.object_id ASC, fd.updated_at DESC;
select * from crm_layouts where crm_configuration_id = 1;
select cf.* from crm_fields cf
join crm_layout_entities cle on cf.id = cle.crm_field_id
where crm_layout_id = 1493;
SELECT * FROM opportunities WHERE id = 4158460;
SELECT * FROM crm_field_data WHERE object_id = 4158460;
select * from users where team_id = 1;
SELECT * FROM users WHERE id = 7160;
select * from role_user where user_id = 3248;
select * from crm_field_values where crm_field_id = 66824;
SELECT * FROM users WHERE id = 23470;
select * from crm_configurations;
# [PASSWORD_DOTS]
select * from teams where id = 1038; # 23521, 966
select * from users where team_id = 1038;
select * from crm_configurations where id = 966;
SELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762
SELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764
SELECT * FROM participants WHERE activity_id = 54965764;
SELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075
SELECT * FROM participants WHERE activity_id = 54964075;
select f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id
where tf.team_id = 1038;
# [PASSWORD_DOTS] PD [PASSWORD_DOTS]
select * from teams where id = 1029;
SELECT * FROM crm_configurations WHERE id = 957;
SELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423
SELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433
SELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421
# [PASSWORD_DOTS] Close [PASSWORD_DOTS]
select * from teams where id = 1031;
SELECT * FROM crm_configurations WHERE id = 959;
SELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517
SELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433
SELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009
# [PASSWORD_DOTS] SF [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;
select * from roles;
select * from permissions;
select * from permission_role where permission_id = 136;
select * from migrations order by id desc;
select * from teams where id IN (1, 1037);
select * from crm_layouts where crm_configuration_id = 1;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;
SELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);
select * from features;
select * from team_features where feature_id = 33;
select * from opportunities;
select * from teams;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1052 and sa.provider = 'hubspot';
SELECT * FROM accounts where id = 11512582;
select * from activities where account_id = 11512582;
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45506
|
1627
|
34
|
2026-05-14T14:40:26.332373+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769626332_m1.jpg...
|
PhpStorm
|
faVsco.js – PlainTextDecorateActivity.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% CFV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit K vHandleHubspotRateLimitTestv8• Thu 14 May 17:40:26* :QProject vTrackAutomatedReportGeneratedEvent.phpQ.AutomatedReportResult.phpSendReportJob.phpv D DecorateActivityDeleteAccountJob.phpImportActivitylypes.php0 WriteCrmTrait.phpDecorateActivity.php© BaseDecorateActivity.php© IntegrationApp/Service.php© Activity.phpSalesforce/Service.phpT LogActivityTrait.php© PlainTextDecorateActivity.phpDummy© Pipedrive/Service.phpClose/Service.phpCopper/Service.php© BullhornService.phpv • Helpers© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.php=.env.staging18ActivityPlaybookTrait.php© Arraylterator.php© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.php• ConnectionStateTrait.php© HubspotPaginationService.php© CrmHelperRepository.php© HandleHubspotRateLimit.phpnamespace Jiminny (Services\Crm DecorateActivity;© FilterJoinedParticipants.php• OpportunitySyncableFieldsTreusev D Hubspot• AccountSyncStrategy@ Actions1314 @15class PlainTextDecorateActivity extends BaseDecorateActivity• ContactSyncStrategy14 usagesO DTO16private const string SECTION_SEPARATOR = PHP_EOL - PHP_EOL;• Fields17• Journal18 Cpublic function generateTitle(Activity $activity): ?string• Metadata19{~ D OpportunitySyncStrategy20> Concerns21© HubspotLastModifiedByPrc22© HubspotLastModifiedCreat23© HubspotLastModifiedCreat24Stitle = null;if ($activity->hasTitleO) {Stitle = Sactivity->getTitleO;} elseif (Sactivity->hasActivityTypeO) {S$title = Sactivity->getActivityType()->getName;© HubspotLastModifiedOpen25© HubspotLastModifiedSync26© HubspotSingleSyncStrateg27© HubspotSyncStrategyBase28© HubspotWebhookBatchSyi29v 0 Pagination30switch (Sactivity->getCrmTypeO) {case Activity::TYPE_SMS_INBOUND:case Activity::TYPE_SMS_OUTBOUND:Stitle = Str::limit(Sactivity->getDescription®,limit: 250);© HubspotPaginationService31© PaginationConfig.php32break;© PaginationState.php33• ProspectSearchStrategy34case Activity::TYPE_SOFTPHONE_INBOUND:C Redis35if ($title === null) {v M ContinaTraiteWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)DeleteCrmEntityTrait.php:IntegrationAppServiceTrait.php© Playbook.phpPlainTextDecorateActivity.php X=.envClient.php41 ×2=custom.log= laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 ^215•m_layout_id =2162,1661,66799,66217218219id = 33;220221222223224id THEN •(own225226227228229230hubspot':-231—232233 V= 11512582;234W Windsurf Teams23:45 (15 chars)UTF-8Ca 4 spaces...
|
NULL
|
-1744135923137966366
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% CFV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit K vHandleHubspotRateLimitTestv8• Thu 14 May 17:40:26* :QProject vTrackAutomatedReportGeneratedEvent.phpQ.AutomatedReportResult.phpSendReportJob.phpv D DecorateActivityDeleteAccountJob.phpImportActivitylypes.php0 WriteCrmTrait.phpDecorateActivity.php© BaseDecorateActivity.php© IntegrationApp/Service.php© Activity.phpSalesforce/Service.phpT LogActivityTrait.php© PlainTextDecorateActivity.phpDummy© Pipedrive/Service.phpClose/Service.phpCopper/Service.php© BullhornService.phpv • Helpers© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.php=.env.staging18ActivityPlaybookTrait.php© Arraylterator.php© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.php• ConnectionStateTrait.php© HubspotPaginationService.php© CrmHelperRepository.php© HandleHubspotRateLimit.phpnamespace Jiminny (Services\Crm DecorateActivity;© FilterJoinedParticipants.php• OpportunitySyncableFieldsTreusev D Hubspot• AccountSyncStrategy@ Actions1314 @15class PlainTextDecorateActivity extends BaseDecorateActivity• ContactSyncStrategy14 usagesO DTO16private const string SECTION_SEPARATOR = PHP_EOL - PHP_EOL;• Fields17• Journal18 Cpublic function generateTitle(Activity $activity): ?string• Metadata19{~ D OpportunitySyncStrategy20> Concerns21© HubspotLastModifiedByPrc22© HubspotLastModifiedCreat23© HubspotLastModifiedCreat24Stitle = null;if ($activity->hasTitleO) {Stitle = Sactivity->getTitleO;} elseif (Sactivity->hasActivityTypeO) {S$title = Sactivity->getActivityType()->getName;© HubspotLastModifiedOpen25© HubspotLastModifiedSync26© HubspotSingleSyncStrateg27© HubspotSyncStrategyBase28© HubspotWebhookBatchSyi29v 0 Pagination30switch (Sactivity->getCrmTypeO) {case Activity::TYPE_SMS_INBOUND:case Activity::TYPE_SMS_OUTBOUND:Stitle = Str::limit(Sactivity->getDescription®,limit: 250);© HubspotPaginationService31© PaginationConfig.php32break;© PaginationState.php33• ProspectSearchStrategy34case Activity::TYPE_SOFTPHONE_INBOUND:C Redis35if ($title === null) {v M ContinaTraiteWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)DeleteCrmEntityTrait.php:IntegrationAppServiceTrait.php© Playbook.phpPlainTextDecorateActivity.php X=.envClient.php41 ×2=custom.log= laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 ^215•m_layout_id =2162,1661,66799,66217218219id = 33;220221222223224id THEN •(own225226227228229230hubspot':-231—232233 V= 11512582;234W Windsurf Teams23:45 (15 chars)UTF-8Ca 4 spaces...
|
45505
|
NULL
|
NULL
|
NULL
|
|
45505
|
1627
|
33
|
2026-05-14T14:40:23.157843+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769623157_m1.jpg...
|
PhpStorm
|
faVsco.js – PlainTextDecorateActivity.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% CFV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit K vHandleHubspotRateLimitTest8• Thu 14 May 17:40:23* :QProject vTrackAutomatedReportGeneratedEvent.phpQ.AutomatedReportResult.phpSendReportJob.php© FieldTypeConverter.phpDeleteAccountJob.phpImportActivitylypes.php0 WriteCrmTrait.phpDecorateActivity.php• HubspotClientinterface.php© HubspotTokenManager.php© IntegrationApp/Service.php© Activity.phpSalesforce/Service.phpT LogActivityTrait.php© PayloadBuilder.phpPipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php© RemoteCrmObjectManipulatoi© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.php=.env.staging18© ResponseNormalize.php© Service.phpDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.php© SyncFieldAction.php© SyncRelatedActivityManager.|© HubspotPaginationService.php© WebhookSyncBatchProcessol© HandleHubspotRateLimit.phpnamespace Jiminny (Services\Crm DecorateActivity;v D IntegrationAppuse• Accessors• АріConfig1314 @15class PlainTextDecorateActivity extends BaseDecorateActivityO DTO> O Filters14 usages16v d Jobsprivate const string SECTION_SEPARATOR = PHP_EOL - PHP_EOL;© CrmEntitiesFullSyncJob.ph1718 €public function generateTitle(Activity $activity): ?string© DeleteRemoteTeamJob.phT IntegrationAppService Trait19{20© SubscribeForEventsJob.ph21© TeamInitialSyncJob.php22© UnsubscribeForEventsJob.23© UpdateProfileRelatedEntiti24Stitle = null;if ($activity->hasTitleO) {Stitle = $activity->getTitleO;} elseif ($activity->hasActivityTypeO) {S$title = Sactivity->getActivityType()->getName;© ValidateTeamActiveConne> C ProspectSearchStrategy2526> D ServiceTraits© DataClient.php2728© DecorateActivity.php© LocalSearch.php2930• LocalSearchlnterface.phpswitch (Sactivity->getCrmTypeO) {case Activity::TYPE_SMS_INBOUND:case Activity::TYPE_SMS_OUTBOUND:Stitle = Str::limit(Sactivity->getDescription®,limit: 250);31© RemoteSearch.php32break;© Service.phpv • Listeners3334C ConvertLeadActivities.phpcase Activity::TYPE_SOFTPHONE_INBOUND:© Purnel onkunCache nhn35if ($title === null) {Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)DeleteCrmEntityTrait.phpIntegrationAppServiceTrait.php© Playbook.phpPlainTextDecorateActivity.php X=.envClient.php81 ×2=custom.log= laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66217218219id = 33;220221222223224id THEN •(own225226227228229230hubspot':-231—232233 V= 11512582;234W Windsurf Teams418:58UTF-8Ca 4 spaces...
|
NULL
|
-3970800708242880997
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% CFV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit K vHandleHubspotRateLimitTest8• Thu 14 May 17:40:23* :QProject vTrackAutomatedReportGeneratedEvent.phpQ.AutomatedReportResult.phpSendReportJob.php© FieldTypeConverter.phpDeleteAccountJob.phpImportActivitylypes.php0 WriteCrmTrait.phpDecorateActivity.php• HubspotClientinterface.php© HubspotTokenManager.php© IntegrationApp/Service.php© Activity.phpSalesforce/Service.phpT LogActivityTrait.php© PayloadBuilder.phpPipedrive/Service.phpClose/Service.phpCopper/Service.phpBullhornService.php© RemoteCrmObjectManipulatoi© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.php=.env.staging18© ResponseNormalize.php© Service.phpDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.php© SyncFieldAction.php© SyncRelatedActivityManager.|© HubspotPaginationService.php© WebhookSyncBatchProcessol© HandleHubspotRateLimit.phpnamespace Jiminny (Services\Crm DecorateActivity;v D IntegrationAppuse• Accessors• АріConfig1314 @15class PlainTextDecorateActivity extends BaseDecorateActivityO DTO> O Filters14 usages16v d Jobsprivate const string SECTION_SEPARATOR = PHP_EOL - PHP_EOL;© CrmEntitiesFullSyncJob.ph1718 €public function generateTitle(Activity $activity): ?string© DeleteRemoteTeamJob.phT IntegrationAppService Trait19{20© SubscribeForEventsJob.ph21© TeamInitialSyncJob.php22© UnsubscribeForEventsJob.23© UpdateProfileRelatedEntiti24Stitle = null;if ($activity->hasTitleO) {Stitle = $activity->getTitleO;} elseif ($activity->hasActivityTypeO) {S$title = Sactivity->getActivityType()->getName;© ValidateTeamActiveConne> C ProspectSearchStrategy2526> D ServiceTraits© DataClient.php2728© DecorateActivity.php© LocalSearch.php2930• LocalSearchlnterface.phpswitch (Sactivity->getCrmTypeO) {case Activity::TYPE_SMS_INBOUND:case Activity::TYPE_SMS_OUTBOUND:Stitle = Str::limit(Sactivity->getDescription®,limit: 250);31© RemoteSearch.php32break;© Service.phpv • Listeners3334C ConvertLeadActivities.phpcase Activity::TYPE_SOFTPHONE_INBOUND:© Purnel onkunCache nhn35if ($title === null) {Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)DeleteCrmEntityTrait.phpIntegrationAppServiceTrait.php© Playbook.phpPlainTextDecorateActivity.php X=.envClient.php81 ×2=custom.log= laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:4 console [QAI PROD] X4 console [PROD]A console (EU]DGo jiminny084143 ×4 л215•m_layout_id =2162,1661,66799,66217218219id = 33;220221222223224id THEN •(own225226227228229230hubspot':-231—232233 V= 11512582;234W Windsurf Teams418:58UTF-8Ca 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45504
|
1628
|
27
|
2026-05-14T14:40:25.091644+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769625091_m2.jpg...
|
PhpStorm
|
faVsco.js – PlainTextDecorateActivity.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\DecorateActivity;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Jiminny\Models\Activity;
use Jiminny\Repositories\ActivityMessageRepository;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Utils\PlaybackUrlBuilder;
class PlainTextDecorateActivity extends BaseDecorateActivity
{
private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;
public function generateTitle(Activity $activity): ?string
{
$title = null;
if ($activity->hasTitle()) {
$title = $activity->getTitle();
} elseif ($activity->hasActivityType()) {
$title = $activity->getActivityType()->getName();
}
switch ($activity->getCrmType()) {
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$title = Str::limit($activity->getDescription(), 250);
break;
case Activity::TYPE_SOFTPHONE_INBOUND:
if ($title === null) {
$title = 'Inbound Call';
}
break;
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_CONFERENCE:
default:
if ($title === null) {
$title = 'Outbound Call';
}
break;
}
return $title;
}
public function generateDescription(Activity $activity): ?string
{
$description = '';
switch ($activity->getCrmType()) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$description = $this->getCallHeading($activity);
if ($activity->isTypeSoftphoneInbound()) {
$description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;
} else {
$description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;
}
$description .= $this->getPreviewUrl($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_CONFERENCE:
$description = $this->getCallHeading($activity);
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' .
self::SECTION_SEPARATOR;
} else {
$description .= $this->getPreviewUrl($activity);
}
$description .= self::SECTION_ATTENDEES . ':'
. PHP_EOL
. app(FilterJoinedParticipants::class)->toString($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
default:
if (mb_strlen($activity->getDescription() ?? '') > 250) {
$description = $activity->getDescription();
}
break;
}
return $description;
}
public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string
{
if (empty($existingCrmDescription)) {
return $jiminnyDescription;
}
if (! $this->isJiminnyDescription($existingCrmDescription)) {
return $existingCrmDescription
. self::SECTION_SEPARATOR
. str_repeat('-', 50)
. PHP_EOL
. $jiminnyDescription;
}
$existingSections = $this->parseSections($existingCrmDescription);
$newSections = $this->parseSections($jiminnyDescription);
return $this->mergeSections($newSections, $existingSections);
}
private function getCallHeading(Activity $activity): string
{
$description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;
$description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;
return $description;
}
private function getPreviewUrl(Activity $activity): string
{
$description = '';
if ($activity->canReviewActivity()) {
$playbackUrl = PlaybackUrlBuilder::build($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;
}
return $description;
}
private function getActivityNotes(Activity $activity): string
{
if ($activity->getNotes()->isEmpty()) {
return '';
}
$description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;
foreach ($activity->getNotes() as $note) {
$time = ($note->getTime() > 3600)
? gmdate('H:i:s', (int) $note->getTime())
: gmdate('i:s', (int) $note->getTime());
$description .= $time . ' ' . $note->getNote() . PHP_EOL;
}
return $description;
}
private function getActivityMessages(Activity $activity): string
{
return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);
}
private function getCoachingChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$privateMessages = $messageRepo->getPrivateMessages($activity);
if ($privateMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($privateMessages);
}
private function getCustomerChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$publicMessages = $messageRepo->getPublicMessages($activity);
if ($publicMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($publicMessages);
}
private function getMessagesAsString(Collection $messages): string
{
$description = '';
/** @var Activity\Message $message */
foreach ($messages as $message) {
$description .= $message->participant->getName()
. ': '
. $message->getAttribute('message')
. PHP_EOL;
}
return $description;
}
private function getSummaryField(Activity $activity): string
{
if ($activity->getSummary() === null) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();
}
private function parseSections(string $description): array
{
$sections = [
'Original Content' => '',
'Header' => '',
];
foreach (self::SECTION_HEADERS as $sectionName) {
$sections[$sectionName] = '';
}
$markerPos = strpos($description, self::JIMINNY_MARKER);
if ($markerPos !== false && $markerPos > 0) {
$sections['Original Content'] = trim(substr($description, 0, $markerPos));
}
$jiminnyStart = $markerPos !== false ? $markerPos : 0;
$headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);
if ($headerEndPos === false) {
$sections['Header'] = trim(substr($description, $jiminnyStart));
return $sections;
}
$sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));
foreach (self::SECTION_HEADERS as $sectionName) {
$sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);
if ($sectionContent !== null) {
$sections[$sectionName] = $sectionContent;
}
}
return $sections;
}
private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false
{
$firstPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {
$firstPos = $pos;
}
}
return $firstPos;
}
private function extractSectionContent(
string $description,
string $sectionName,
int $afterPosition = 0
): ?string {
$sectionStart = strpos($description, $sectionName . ':', $afterPosition);
if ($sectionStart === false) {
return null;
}
$contentStart = $sectionStart + strlen($sectionName . ':');
$nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);
$endPos = strlen($description);
if ($nextSectionPos !== false) {
$endPos = $nextSectionPos;
}
return trim(substr($description, $contentStart, $endPos - $contentStart));
}
private function findNextSectionPosition(
string $description,
int $afterPosition,
string $currentSection = ''
): int|false {
$nextPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
if ($sectionName === $currentSection) {
continue;
}
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {
$nextPos = $pos;
}
}
return $nextPos;
}
private function mergeSections(array $newSections, array $existingSections): string
{
$result = '';
$originalContent = $existingSections['Original Content'] ?? '';
if (! empty($originalContent)) {
$result .= $originalContent . self::SECTION_SEPARATOR;
}
$result .= $newSections['Header'];
foreach (self::SECTION_HEADERS as $sectionName) {
$newContent = $newSections[$sectionName] ?? '';
$existingContent = $existingSections[$sectionName] ?? '';
$mergedContent = $this->mergeSectionContent($newContent, $existingContent);
if (! empty($mergedContent)) {
$result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;
}
}
return $result;
}
private function mergeSectionContent(string $newContent, string $existingContent): string
{
if (! empty($existingContent)) {
return $existingContent;
}
return $newContent;
}
}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\DecorateActivity;\n\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Repositories\\ActivityMessageRepository;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\n\nclass PlainTextDecorateActivity extends BaseDecorateActivity\n{\n private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;\n\n public function generateTitle(Activity $activity): ?string\n {\n $title = null;\n if ($activity->hasTitle()) {\n $title = $activity->getTitle();\n } elseif ($activity->hasActivityType()) {\n $title = $activity->getActivityType()->getName();\n }\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $title = Str::limit($activity->getDescription(), 250);\n\n break;\n\n case Activity::TYPE_SOFTPHONE_INBOUND:\n if ($title === null) {\n $title = 'Inbound Call';\n }\n\n break;\n\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_CONFERENCE:\n default:\n if ($title === null) {\n $title = 'Outbound Call';\n }\n\n break;\n }\n\n return $title;\n }\n\n public function generateDescription(Activity $activity): ?string\n {\n $description = '';\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $description = $this->getCallHeading($activity);\n\n if ($activity->isTypeSoftphoneInbound()) {\n $description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;\n } else {\n $description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;\n }\n\n $description .= $this->getPreviewUrl($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_CONFERENCE:\n $description = $this->getCallHeading($activity);\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' .\n self::SECTION_SEPARATOR;\n } else {\n $description .= $this->getPreviewUrl($activity);\n }\n\n $description .= self::SECTION_ATTENDEES . ':'\n . PHP_EOL\n . app(FilterJoinedParticipants::class)->toString($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n default:\n if (mb_strlen($activity->getDescription() ?? '') > 250) {\n $description = $activity->getDescription();\n }\n\n break;\n }\n\n return $description;\n }\n\n public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string\n {\n if (empty($existingCrmDescription)) {\n return $jiminnyDescription;\n }\n\n if (! $this->isJiminnyDescription($existingCrmDescription)) {\n return $existingCrmDescription\n . self::SECTION_SEPARATOR\n . str_repeat('-', 50)\n . PHP_EOL\n . $jiminnyDescription;\n }\n\n $existingSections = $this->parseSections($existingCrmDescription);\n $newSections = $this->parseSections($jiminnyDescription);\n\n return $this->mergeSections($newSections, $existingSections);\n }\n\n private function getCallHeading(Activity $activity): string\n {\n $description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;\n $description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;\n\n return $description;\n }\n\n private function getPreviewUrl(Activity $activity): string\n {\n $description = '';\n if ($activity->canReviewActivity()) {\n $playbackUrl = PlaybackUrlBuilder::build($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;\n }\n\n return $description;\n }\n\n private function getActivityNotes(Activity $activity): string\n {\n if ($activity->getNotes()->isEmpty()) {\n return '';\n }\n\n $description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;\n\n foreach ($activity->getNotes() as $note) {\n $time = ($note->getTime() > 3600)\n ? gmdate('H:i:s', (int) $note->getTime())\n : gmdate('i:s', (int) $note->getTime());\n\n $description .= $time . ' ' . $note->getNote() . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getActivityMessages(Activity $activity): string\n {\n return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);\n }\n\n private function getCoachingChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $privateMessages = $messageRepo->getPrivateMessages($activity);\n\n if ($privateMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($privateMessages);\n }\n\n private function getCustomerChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $publicMessages = $messageRepo->getPublicMessages($activity);\n\n if ($publicMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($publicMessages);\n }\n\n private function getMessagesAsString(Collection $messages): string\n {\n $description = '';\n\n /** @var Activity\\Message $message */\n foreach ($messages as $message) {\n $description .= $message->participant->getName()\n . ': '\n . $message->getAttribute('message')\n . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getSummaryField(Activity $activity): string\n {\n if ($activity->getSummary() === null) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();\n }\n\n private function parseSections(string $description): array\n {\n $sections = [\n 'Original Content' => '',\n 'Header' => '',\n ];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sections[$sectionName] = '';\n }\n\n $markerPos = strpos($description, self::JIMINNY_MARKER);\n if ($markerPos !== false && $markerPos > 0) {\n $sections['Original Content'] = trim(substr($description, 0, $markerPos));\n }\n\n $jiminnyStart = $markerPos !== false ? $markerPos : 0;\n $headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);\n\n if ($headerEndPos === false) {\n $sections['Header'] = trim(substr($description, $jiminnyStart));\n\n return $sections;\n }\n\n $sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);\n if ($sectionContent !== null) {\n $sections[$sectionName] = $sectionContent;\n }\n }\n\n return $sections;\n }\n\n private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false\n {\n $firstPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {\n $firstPos = $pos;\n }\n }\n\n return $firstPos;\n }\n\n private function extractSectionContent(\n string $description,\n string $sectionName,\n int $afterPosition = 0\n ): ?string {\n $sectionStart = strpos($description, $sectionName . ':', $afterPosition);\n if ($sectionStart === false) {\n return null;\n }\n\n $contentStart = $sectionStart + strlen($sectionName . ':');\n $nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);\n\n $endPos = strlen($description);\n if ($nextSectionPos !== false) {\n $endPos = $nextSectionPos;\n }\n\n return trim(substr($description, $contentStart, $endPos - $contentStart));\n }\n\n private function findNextSectionPosition(\n string $description,\n int $afterPosition,\n string $currentSection = ''\n ): int|false {\n $nextPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n if ($sectionName === $currentSection) {\n continue;\n }\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {\n $nextPos = $pos;\n }\n }\n\n return $nextPos;\n }\n\n private function mergeSections(array $newSections, array $existingSections): string\n {\n $result = '';\n\n $originalContent = $existingSections['Original Content'] ?? '';\n if (! empty($originalContent)) {\n $result .= $originalContent . self::SECTION_SEPARATOR;\n }\n\n $result .= $newSections['Header'];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $newContent = $newSections[$sectionName] ?? '';\n $existingContent = $existingSections[$sectionName] ?? '';\n\n $mergedContent = $this->mergeSectionContent($newContent, $existingContent);\n if (! empty($mergedContent)) {\n $result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;\n }\n }\n\n return $result;\n }\n\n private function mergeSectionContent(string $newContent, string $existingContent): string\n {\n if (! empty($existingContent)) {\n return $existingContent;\n }\n\n return $newContent;\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\DecorateActivity;\n\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Repositories\\ActivityMessageRepository;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\n\nclass PlainTextDecorateActivity extends BaseDecorateActivity\n{\n private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;\n\n public function generateTitle(Activity $activity): ?string\n {\n $title = null;\n if ($activity->hasTitle()) {\n $title = $activity->getTitle();\n } elseif ($activity->hasActivityType()) {\n $title = $activity->getActivityType()->getName();\n }\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $title = Str::limit($activity->getDescription(), 250);\n\n break;\n\n case Activity::TYPE_SOFTPHONE_INBOUND:\n if ($title === null) {\n $title = 'Inbound Call';\n }\n\n break;\n\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_CONFERENCE:\n default:\n if ($title === null) {\n $title = 'Outbound Call';\n }\n\n break;\n }\n\n return $title;\n }\n\n public function generateDescription(Activity $activity): ?string\n {\n $description = '';\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $description = $this->getCallHeading($activity);\n\n if ($activity->isTypeSoftphoneInbound()) {\n $description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;\n } else {\n $description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;\n }\n\n $description .= $this->getPreviewUrl($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_CONFERENCE:\n $description = $this->getCallHeading($activity);\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' .\n self::SECTION_SEPARATOR;\n } else {\n $description .= $this->getPreviewUrl($activity);\n }\n\n $description .= self::SECTION_ATTENDEES . ':'\n . PHP_EOL\n . app(FilterJoinedParticipants::class)->toString($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n default:\n if (mb_strlen($activity->getDescription() ?? '') > 250) {\n $description = $activity->getDescription();\n }\n\n break;\n }\n\n return $description;\n }\n\n public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string\n {\n if (empty($existingCrmDescription)) {\n return $jiminnyDescription;\n }\n\n if (! $this->isJiminnyDescription($existingCrmDescription)) {\n return $existingCrmDescription\n . self::SECTION_SEPARATOR\n . str_repeat('-', 50)\n . PHP_EOL\n . $jiminnyDescription;\n }\n\n $existingSections = $this->parseSections($existingCrmDescription);\n $newSections = $this->parseSections($jiminnyDescription);\n\n return $this->mergeSections($newSections, $existingSections);\n }\n\n private function getCallHeading(Activity $activity): string\n {\n $description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;\n $description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;\n\n return $description;\n }\n\n private function getPreviewUrl(Activity $activity): string\n {\n $description = '';\n if ($activity->canReviewActivity()) {\n $playbackUrl = PlaybackUrlBuilder::build($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;\n }\n\n return $description;\n }\n\n private function getActivityNotes(Activity $activity): string\n {\n if ($activity->getNotes()->isEmpty()) {\n return '';\n }\n\n $description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;\n\n foreach ($activity->getNotes() as $note) {\n $time = ($note->getTime() > 3600)\n ? gmdate('H:i:s', (int) $note->getTime())\n : gmdate('i:s', (int) $note->getTime());\n\n $description .= $time . ' ' . $note->getNote() . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getActivityMessages(Activity $activity): string\n {\n return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);\n }\n\n private function getCoachingChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $privateMessages = $messageRepo->getPrivateMessages($activity);\n\n if ($privateMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($privateMessages);\n }\n\n private function getCustomerChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $publicMessages = $messageRepo->getPublicMessages($activity);\n\n if ($publicMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($publicMessages);\n }\n\n private function getMessagesAsString(Collection $messages): string\n {\n $description = '';\n\n /** @var Activity\\Message $message */\n foreach ($messages as $message) {\n $description .= $message->participant->getName()\n . ': '\n . $message->getAttribute('message')\n . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getSummaryField(Activity $activity): string\n {\n if ($activity->getSummary() === null) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();\n }\n\n private function parseSections(string $description): array\n {\n $sections = [\n 'Original Content' => '',\n 'Header' => '',\n ];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sections[$sectionName] = '';\n }\n\n $markerPos = strpos($description, self::JIMINNY_MARKER);\n if ($markerPos !== false && $markerPos > 0) {\n $sections['Original Content'] = trim(substr($description, 0, $markerPos));\n }\n\n $jiminnyStart = $markerPos !== false ? $markerPos : 0;\n $headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);\n\n if ($headerEndPos === false) {\n $sections['Header'] = trim(substr($description, $jiminnyStart));\n\n return $sections;\n }\n\n $sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);\n if ($sectionContent !== null) {\n $sections[$sectionName] = $sectionContent;\n }\n }\n\n return $sections;\n }\n\n private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false\n {\n $firstPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {\n $firstPos = $pos;\n }\n }\n\n return $firstPos;\n }\n\n private function extractSectionContent(\n string $description,\n string $sectionName,\n int $afterPosition = 0\n ): ?string {\n $sectionStart = strpos($description, $sectionName . ':', $afterPosition);\n if ($sectionStart === false) {\n return null;\n }\n\n $contentStart = $sectionStart + strlen($sectionName . ':');\n $nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);\n\n $endPos = strlen($description);\n if ($nextSectionPos !== false) {\n $endPos = $nextSectionPos;\n }\n\n return trim(substr($description, $contentStart, $endPos - $contentStart));\n }\n\n private function findNextSectionPosition(\n string $description,\n int $afterPosition,\n string $currentSection = ''\n ): int|false {\n $nextPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n if ($sectionName === $currentSection) {\n continue;\n }\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {\n $nextPos = $pos;\n }\n }\n\n return $nextPos;\n }\n\n private function mergeSections(array $newSections, array $existingSections): string\n {\n $result = '';\n\n $originalContent = $existingSections['Original Content'] ?? '';\n if (! empty($originalContent)) {\n $result .= $originalContent . self::SECTION_SEPARATOR;\n }\n\n $result .= $newSections['Header'];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $newContent = $newSections[$sectionName] ?? '';\n $existingContent = $existingSections[$sectionName] ?? '';\n\n $mergedContent = $this->mergeSectionContent($newContent, $existingContent);\n if (! empty($mergedContent)) {\n $result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;\n }\n }\n\n return $result;\n }\n\n private function mergeSectionContent(string $newContent, string $existingContent): string\n {\n if (! empty($existingContent)) {\n return $existingContent;\n }\n\n return $newContent;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
2177749799374614095
|
-4703435100469535437
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\DecorateActivity;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Jiminny\Models\Activity;
use Jiminny\Repositories\ActivityMessageRepository;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Utils\PlaybackUrlBuilder;
class PlainTextDecorateActivity extends BaseDecorateActivity
{
private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;
public function generateTitle(Activity $activity): ?string
{
$title = null;
if ($activity->hasTitle()) {
$title = $activity->getTitle();
} elseif ($activity->hasActivityType()) {
$title = $activity->getActivityType()->getName();
}
switch ($activity->getCrmType()) {
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$title = Str::limit($activity->getDescription(), 250);
break;
case Activity::TYPE_SOFTPHONE_INBOUND:
if ($title === null) {
$title = 'Inbound Call';
}
break;
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_CONFERENCE:
default:
if ($title === null) {
$title = 'Outbound Call';
}
break;
}
return $title;
}
public function generateDescription(Activity $activity): ?string
{
$description = '';
switch ($activity->getCrmType()) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$description = $this->getCallHeading($activity);
if ($activity->isTypeSoftphoneInbound()) {
$description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;
} else {
$description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;
}
$description .= $this->getPreviewUrl($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_CONFERENCE:
$description = $this->getCallHeading($activity);
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' .
self::SECTION_SEPARATOR;
} else {
$description .= $this->getPreviewUrl($activity);
}
$description .= self::SECTION_ATTENDEES . ':'
. PHP_EOL
. app(FilterJoinedParticipants::class)->toString($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
default:
if (mb_strlen($activity->getDescription() ?? '') > 250) {
$description = $activity->getDescription();
}
break;
}
return $description;
}
public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string
{
if (empty($existingCrmDescription)) {
return $jiminnyDescription;
}
if (! $this->isJiminnyDescription($existingCrmDescription)) {
return $existingCrmDescription
. self::SECTION_SEPARATOR
. str_repeat('-', 50)
. PHP_EOL
. $jiminnyDescription;
}
$existingSections = $this->parseSections($existingCrmDescription);
$newSections = $this->parseSections($jiminnyDescription);
return $this->mergeSections($newSections, $existingSections);
}
private function getCallHeading(Activity $activity): string
{
$description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;
$description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;
return $description;
}
private function getPreviewUrl(Activity $activity): string
{
$description = '';
if ($activity->canReviewActivity()) {
$playbackUrl = PlaybackUrlBuilder::build($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;
}
return $description;
}
private function getActivityNotes(Activity $activity): string
{
if ($activity->getNotes()->isEmpty()) {
return '';
}
$description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;
foreach ($activity->getNotes() as $note) {
$time = ($note->getTime() > 3600)
? gmdate('H:i:s', (int) $note->getTime())
: gmdate('i:s', (int) $note->getTime());
$description .= $time . ' ' . $note->getNote() . PHP_EOL;
}
return $description;
}
private function getActivityMessages(Activity $activity): string
{
return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);
}
private function getCoachingChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$privateMessages = $messageRepo->getPrivateMessages($activity);
if ($privateMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($privateMessages);
}
private function getCustomerChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$publicMessages = $messageRepo->getPublicMessages($activity);
if ($publicMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($publicMessages);
}
private function getMessagesAsString(Collection $messages): string
{
$description = '';
/** @var Activity\Message $message */
foreach ($messages as $message) {
$description .= $message->participant->getName()
. ': '
. $message->getAttribute('message')
. PHP_EOL;
}
return $description;
}
private function getSummaryField(Activity $activity): string
{
if ($activity->getSummary() === null) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();
}
private function parseSections(string $description): array
{
$sections = [
'Original Content' => '',
'Header' => '',
];
foreach (self::SECTION_HEADERS as $sectionName) {
$sections[$sectionName] = '';
}
$markerPos = strpos($description, self::JIMINNY_MARKER);
if ($markerPos !== false && $markerPos > 0) {
$sections['Original Content'] = trim(substr($description, 0, $markerPos));
}
$jiminnyStart = $markerPos !== false ? $markerPos : 0;
$headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);
if ($headerEndPos === false) {
$sections['Header'] = trim(substr($description, $jiminnyStart));
return $sections;
}
$sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));
foreach (self::SECTION_HEADERS as $sectionName) {
$sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);
if ($sectionContent !== null) {
$sections[$sectionName] = $sectionContent;
}
}
return $sections;
}
private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false
{
$firstPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {
$firstPos = $pos;
}
}
return $firstPos;
}
private function extractSectionContent(
string $description,
string $sectionName,
int $afterPosition = 0
): ?string {
$sectionStart = strpos($description, $sectionName . ':', $afterPosition);
if ($sectionStart === false) {
return null;
}
$contentStart = $sectionStart + strlen($sectionName . ':');
$nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);
$endPos = strlen($description);
if ($nextSectionPos !== false) {
$endPos = $nextSectionPos;
}
return trim(substr($description, $contentStart, $endPos - $contentStart));
}
private function findNextSectionPosition(
string $description,
int $afterPosition,
string $currentSection = ''
): int|false {
$nextPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
if ($sectionName === $currentSection) {
continue;
}
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {
$nextPos = $pos;
}
}
return $nextPos;
}
private function mergeSections(array $newSections, array $existingSections): string
{
$result = '';
$originalContent = $existingSections['Original Content'] ?? '';
if (! empty($originalContent)) {
$result .= $originalContent . self::SECTION_SEPARATOR;
}
$result .= $newSections['Header'];
foreach (self::SECTION_HEADERS as $sectionName) {
$newContent = $newSections[$sectionName] ?? '';
$existingContent = $existingSections[$sectionName] ?? '';
$mergedContent = $this->mergeSectionContent($newContent, $existingContent);
if (! empty($mergedContent)) {
$result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;
}
}
return $result;
}
private function mergeSectionContent(string $newContent, string $existingContent): string
{
if (! empty($existingContent)) {
return $existingContent;
}
return $newContent;
}
}...
|
45503
|
NULL
|
NULL
|
NULL
|
|
45230
|
1622
|
31
|
2026-05-14T14:26:03.267624+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768763267_m2.jpg...
|
PhpStorm
|
faVsco.js – PlainTextDecorateActivity.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterdav at 13.34Yesterday at 13:33Yesterday at 13.32Yesterdav at 13:32Yesterday at 13:31Yesterdav at 13:30Yesterday at 13:30Yesterday at 13.29Yesterdav at 13:28resterday at 15.21Yesterdav at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:23Yesterdav at 13:25Yesterday at 13.24Yesterdav at 13:23Yesterday at 13:22Yesterday at 13:21Voctordau at 12:20lYesterday at 13.19Yesterdav at 13:19Yesterdav at 13:18Yesterday at 13:18Yesterday at 13:17Yesterday at 13:17Yesterday at 13:16Yesterday at 13.16Yecterdav at 12:15Yesterdav at 13:15Yesterday at 13-14Yesterdav at 13:14Yesterday at 13:13Yesterday at 13:12Yecterdav at 12:12Yesterday at 13.11Yesterdav at 13:10Yesterday at 13:10Yesterday at 13:09Yoctorday at 12:09Yesterdav at 13:08Yesterday at 13.0Yecterdav at 12:07Yesterday at 13:06Yesterdav at 13:0529 K:MPEG-4 movie6 KB12 KBMDEG-A movieMPEG-4 movieMPEG-4 movie7 KBI8 KB37 KB10 K:MPEG-4 movie7 KBMDEG-A movie8 KEMPEG-4 movie8 KBI72 KB14 KB13 KEMPEG-4 movieMPEG-4 movie9 KBMDECA mavie18 KB12 KB|MPEG-4 movie10 KRI16 KB6 KBMPEG-1 movidMPEG-4 movie6KEMPEG-4 movie12 KBMPEG-4 movie23 KB8 K:MPEG-4 movie6 KB6 KB11 KBMDSG.A movicMPEG-4 movie11 KE20 KBMPEG-4 movieMPEG-4 movie34 KB10 K:MPEG-4 movie7 Kp5 KB11 KBMDSG.A movidMPEG-4 movie26KPMDEG-A movie111 KB MPEG-4 movie102 KB88 KB59 KB98 KB97 KBMPEG-4 movieMDEC.A moviaMPEG-4 movieRAKRMDEG-A movie44 KB93 KBMPEG-4 movie78 K:MPEG-A movid50 KBMPEG-4 movie58 KB27 KB7 KPMPEG-4 movieMDEG.A movio12 KB32 KBMPEG-4 movie17 Kг19 KBMDEC A movid32 KB10K8MPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esviconfig.vmlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.56 GB availabld• Inu 14 May 1/.20:04Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-7170423089845542829
|
NULL
|
click
|
ocr
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterdav at 13.34Yesterday at 13:33Yesterday at 13.32Yesterdav at 13:32Yesterday at 13:31Yesterdav at 13:30Yesterday at 13:30Yesterday at 13.29Yesterdav at 13:28resterday at 15.21Yesterdav at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:23Yesterdav at 13:25Yesterday at 13.24Yesterdav at 13:23Yesterday at 13:22Yesterday at 13:21Voctordau at 12:20lYesterday at 13.19Yesterdav at 13:19Yesterdav at 13:18Yesterday at 13:18Yesterday at 13:17Yesterday at 13:17Yesterday at 13:16Yesterday at 13.16Yecterdav at 12:15Yesterdav at 13:15Yesterday at 13-14Yesterdav at 13:14Yesterday at 13:13Yesterday at 13:12Yecterdav at 12:12Yesterday at 13.11Yesterdav at 13:10Yesterday at 13:10Yesterday at 13:09Yoctorday at 12:09Yesterdav at 13:08Yesterday at 13.0Yecterdav at 12:07Yesterday at 13:06Yesterdav at 13:0529 K:MPEG-4 movie6 KB12 KBMDEG-A movieMPEG-4 movieMPEG-4 movie7 KBI8 KB37 KB10 K:MPEG-4 movie7 KBMDEG-A movie8 KEMPEG-4 movie8 KBI72 KB14 KB13 KEMPEG-4 movieMPEG-4 movie9 KBMDECA mavie18 KB12 KB|MPEG-4 movie10 KRI16 KB6 KBMPEG-1 movidMPEG-4 movie6KEMPEG-4 movie12 KBMPEG-4 movie23 KB8 K:MPEG-4 movie6 KB6 KB11 KBMDSG.A movicMPEG-4 movie11 KE20 KBMPEG-4 movieMPEG-4 movie34 KB10 K:MPEG-4 movie7 Kp5 KB11 KBMDSG.A movidMPEG-4 movie26KPMDEG-A movie111 KB MPEG-4 movie102 KB88 KB59 KB98 KB97 KBMPEG-4 movieMDEC.A moviaMPEG-4 movieRAKRMDEG-A movie44 KB93 KBMPEG-4 movie78 K:MPEG-A movid50 KBMPEG-4 movie58 KB27 KB7 KPMPEG-4 movieMDEG.A movio12 KB32 KBMPEG-4 movie17 Kг19 KBMDEC A movid32 KB10K8MPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esviconfig.vmlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.56 GB availabld• Inu 14 May 1/.20:04Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45227
|
NULL
|
NULL
|
NULL
|
|
45229
|
1621
|
58
|
2026-05-14T14:26:05.779755+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768765779_m1.jpg...
|
PhpStorm
|
faVsco.js – PlainTextDecorateActivity.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\DecorateActivity;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Jiminny\Models\Activity;
use Jiminny\Repositories\ActivityMessageRepository;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Utils\PlaybackUrlBuilder;
class PlainTextDecorateActivity extends BaseDecorateActivity
{
private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;
public function generateTitle(Activity $activity): ?string
{
$title = null;
if ($activity->hasTitle()) {
$title = $activity->getTitle();
} elseif ($activity->hasActivityType()) {
$title = $activity->getActivityType()->getName();
}
switch ($activity->getCrmType()) {
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$title = Str::limit($activity->getDescription(), 250);
break;
case Activity::TYPE_SOFTPHONE_INBOUND:
if ($title === null) {
$title = 'Inbound Call';
}
break;
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_CONFERENCE:
default:
if ($title === null) {
$title = 'Outbound Call';
}
break;
}
return $title;
}
public function generateDescription(Activity $activity): ?string
{
$description = '';
switch ($activity->getCrmType()) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$description = $this->getCallHeading($activity);
if ($activity->isTypeSoftphoneInbound()) {
$description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;
} else {
$description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;
}
$description .= $this->getPreviewUrl($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_CONFERENCE:
$description = $this->getCallHeading($activity);
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' .
self::SECTION_SEPARATOR;
} else {
$description .= $this->getPreviewUrl($activity);
}
$description .= self::SECTION_ATTENDEES . ':'
. PHP_EOL
. app(FilterJoinedParticipants::class)->toString($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
default:
if (mb_strlen($activity->getDescription() ?? '') > 250) {
$description = $activity->getDescription();
}
break;
}
return $description;
}
public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string
{
if (empty($existingCrmDescription)) {
return $jiminnyDescription;
}
if (! $this->isJiminnyDescription($existingCrmDescription)) {
return $existingCrmDescription
. self::SECTION_SEPARATOR
. str_repeat('-', 50)
. PHP_EOL
. $jiminnyDescription;
}
$existingSections = $this->parseSections($existingCrmDescription);
$newSections = $this->parseSections($jiminnyDescription);
return $this->mergeSections($newSections, $existingSections);
}
private function getCallHeading(Activity $activity): string
{
$description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;
$description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;
return $description;
}
private function getPreviewUrl(Activity $activity): string
{
$description = '';
if ($activity->canReviewActivity()) {
$playbackUrl = PlaybackUrlBuilder::build($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;
}
return $description;
}
private function getActivityNotes(Activity $activity): string
{
if ($activity->getNotes()->isEmpty()) {
return '';
}
$description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;
foreach ($activity->getNotes() as $note) {
$time = ($note->getTime() > 3600)
? gmdate('H:i:s', (int) $note->getTime())
: gmdate('i:s', (int) $note->getTime());
$description .= $time . ' ' . $note->getNote() . PHP_EOL;
}
return $description;
}
private function getActivityMessages(Activity $activity): string
{
return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);
}
private function getCoachingChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$privateMessages = $messageRepo->getPrivateMessages($activity);
if ($privateMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($privateMessages);
}
private function getCustomerChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$publicMessages = $messageRepo->getPublicMessages($activity);
if ($publicMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($publicMessages);
}
private function getMessagesAsString(Collection $messages): string
{
$description = '';
/** @var Activity\Message $message */
foreach ($messages as $message) {
$description .= $message->participant->getName()
. ': '
. $message->getAttribute('message')
. PHP_EOL;
}
return $description;
}
private function getSummaryField(Activity $activity): string
{
if ($activity->getSummary() === null) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();
}
private function parseSections(string $description): array
{
$sections = [
'Original Content' => '',
'Header' => '',
];
foreach (self::SECTION_HEADERS as $sectionName) {
$sections[$sectionName] = '';
}
$markerPos = strpos($description, self::JIMINNY_MARKER);
if ($markerPos !== false && $markerPos > 0) {
$sections['Original Content'] = trim(substr($description, 0, $markerPos));
}
$jiminnyStart = $markerPos !== false ? $markerPos : 0;
$headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);
if ($headerEndPos === false) {
$sections['Header'] = trim(substr($description, $jiminnyStart));
return $sections;
}
$sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));
foreach (self::SECTION_HEADERS as $sectionName) {
$sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);
if ($sectionContent !== null) {
$sections[$sectionName] = $sectionContent;
}
}
return $sections;
}
private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false
{
$firstPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {
$firstPos = $pos;
}
}
return $firstPos;
}
private function extractSectionContent(
string $description,
string $sectionName,
int $afterPosition = 0
): ?string {
$sectionStart = strpos($description, $sectionName . ':', $afterPosition);
if ($sectionStart === false) {
return null;
}
$contentStart = $sectionStart + strlen($sectionName . ':');
$nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);
$endPos = strlen($description);
if ($nextSectionPos !== false) {
$endPos = $nextSectionPos;
}
return trim(substr($description, $contentStart, $endPos - $contentStart));
}
private function findNextSectionPosition(
string $description,
int $afterPosition,
string $currentSection = ''
): int|false {
$nextPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
if ($sectionName === $currentSection) {
continue;
}
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {
$nextPos = $pos;
}
}
return $nextPos;
}
private function mergeSections(array $newSections, array $existingSections): string
{
$result = '';
$originalContent = $existingSections['Original Content'] ?? '';
if (! empty($originalContent)) {
$result .= $originalContent . self::SECTION_SEPARATOR;
}
$result .= $newSections['Header'];
foreach (self::SECTION_HEADERS as $sectionName) {
$newContent = $newSections[$sectionName] ?? '';
$existingContent = $existingSections[$sectionName] ?? '';
$mergedContent = $this->mergeSectionContent($newContent, $existingContent);
if (! empty($mergedContent)) {
$result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;
}
}
return $result;
}
private function mergeSectionContent(string $newContent, string $existingContent): string
{
if (! empty($existingContent)) {
return $existingContent;
}
return $newContent;
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
3
4
Previous Highlighted Error
Next Highlighted Error
SELECT
# DISTINCT
opp.id as opp_id, opp.uuid, opp.name,
# COUNT(dr.id) AS deal_risk_count,
# dr.id,
# cfd.value,
usr.name AS owner_name,
opp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,
usr.uuid as owner_uuid,
usr.photo_path as owner_photo,
usr.id AS owner_id,
jt.name as owner_job,
opp.stage_id, opp.stage_updated_at,
acc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,
rt.business_process_id AS pipeline_id
FROM opportunities opp
LEFT JOIN record_types rt ON opp.record_type_id = rt.id
LEFT JOIN users usr ON opp.user_id = usr.id
LEFT JOIN accounts acc ON opp.account_id = acc.id
LEFT JOIN job_titles jt ON usr.job_title_id = jt.id
# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id
# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id
LEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)
WHERE opp.user_id IS NOT NULL
AND opp.deleted_at IS NULL
AND opp.is_closed = 1
AND opp.is_won = 0
AND opp.close_date >= '2024-01-01 00:00:00'
AND opp.close_date <= '2024-12-31 23:59:59'
AND usr.team_id = 1
# and opp.id = 4823179
GROUP BY opp.id, cfd.value
ORDER BY
# cfd.value
CAST(cfd.value AS UNSIGNED)
# owner_name
# (SELECT COUNT(dr.id)
# FROM deal_risks dr
# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
# WHERE dr.opportunity_id = opp.id
# AND dr.is_active = 1
# AND gdrt.is_enabled = 1)
DESC
LIMIT 25
# OFFSET 0
;
select * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';
select * from crm_layout_entities where crm_layout_id = 1493;
SELECT * FROM opportunities WHERE id = 4584045;
SELECT * FROM crm_field_data WHERE object_id = 4584045;
SELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'
SELECT
opp.id as opportunity_id,
opp.name,
COUNT(dr.id) as risk_count
FROM opportunities opp
LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id
WHERE opp.id IN (
6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788
)
GROUP BY opp.id;
SELECT COUNT(dr.id)
FROM deal_risks dr
JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
WHERE dr.opportunity_id = 5563344
# AND gdrt.group_id = usr.group_id
EXPLAIN SELECT
opp.id as opp_id, opp.uuid, opp.name,
cfd.value,
cfv.sequence,
cfv.value,
# MAX(cfd.value) AS max_cfd_value,
usr.name AS owner_name,
opp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,
usr.uuid as owner_uuid,
usr.photo_path as owner_photo,
usr.id AS owner_id,
jt.name as owner_job,
opp.stage_id, opp.stage_updated_at,
acc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,
rt.business_process_id AS pipeline_id
FROM opportunities opp
LEFT JOIN record_types rt ON opp.record_type_id = rt.id
LEFT JOIN users usr ON opp.user_id = usr.id
LEFT JOIN accounts acc ON opp.account_id = acc.id
LEFT JOIN job_titles jt ON usr.job_title_id = jt.id
LEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id
FROM crm_field_data sub_cfd
WHERE sub_cfd.object_id = opp.id
AND sub_cfd.crm_field_id = 66810
ORDER BY sub_cfd.updated_at DESC
LIMIT 1)
LEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value
WHERE opp.user_id IS NOT NULL
AND opp.deleted_at IS NULL
AND opp.is_closed = 0
# AND opp.is_closed = 1
# AND opp.is_won = 0
AND opp.close_date >= '2024-01-01 00:00:00'
AND opp.close_date <= '2024-12-31 23:59:59'
AND usr.team_id = 1
# and opp.id = 4823179
# GROUP BY opp.id
ORDER BY
cfv.sequence DESC,
# opp.name
# owner_name
cfd.value
# CAST(cfd.value AS UNSIGNED)
# CAST(MAX(cfd.value) AS UNSIGNED)
# (SELECT COUNT(dr.id)
# FROM deal_risks dr
# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
# WHERE dr.opportunity_id = opp.id
# AND dr.is_active = 1 AND gdrt.is_enabled = 1)
DESC
# LIMIT 75, 25
;
SELECT * FROM crm_field_values WHERE crm_field_id = 66810;
SELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at
FROM crm_fields f
INNER JOIN crm_field_data fd ON fd.crm_field_id = f.id
WHERE (f.crm_configuration_id = 1)
AND (f.object_type = 'opportunity')
# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))
AND (fd.object_id IN (4158460))
AND (f.crm_provider_id IN ('ForecastCategoryName'))
ORDER BY fd.object_id ASC, fd.updated_at DESC;
select * from crm_layouts where crm_configuration_id = 1;
select cf.* from crm_fields cf
join crm_layout_entities cle on cf.id = cle.crm_field_id
where crm_layout_id = 1493;
SELECT * FROM opportunities WHERE id = 4158460;
SELECT * FROM crm_field_data WHERE object_id = 4158460;
select * from users where team_id = 1;
SELECT * FROM users WHERE id = 7160;
select * from role_user where user_id = 3248;
select * from crm_field_values where crm_field_id = 66824;
SELECT * FROM users WHERE id = 23470;
select * from crm_configurations;
# [PASSWORD_DOTS]
select * from teams where id = 1038; # 23521, 966
select * from users where team_id = 1038;
select * from crm_configurations where id = 966;
SELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762
SELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764
SELECT * FROM participants WHERE activity_id = 54965764;
SELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075
SELECT * FROM participants WHERE activity_id = 54964075;
select f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id
where tf.team_id = 1038;
# [PASSWORD_DOTS] PD [PASSWORD_DOTS]
select * from teams where id = 1029;
SELECT * FROM crm_configurations WHERE id = 957;
SELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423
SELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433
SELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421
# [PASSWORD_DOTS] Close [PASSWORD_DOTS]
select * from teams where id = 1031;
SELECT * FROM crm_configurations WHERE id = 959;
SELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517
SELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433
SELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009
# [PASSWORD_DOTS] SF [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;
select * from roles;
select * from permissions;
select * from permission_role where permission_id = 136;
select * from migrations order by id desc;
select * from teams where id IN (1, 1037);
select * from crm_layouts where crm_configuration_id = 1;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;
SELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);
select * from features;
select * from team_features where feature_id = 33;
select * from opportunities;
select * from teams;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1052 and sa.provider = 'hubspot';
SELECT * FROM accounts where id = 11512582;
select * from activities where account_id = 11512582;
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.78194445,"top":0.3122222,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"2","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\DecorateActivity;\n\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Repositories\\ActivityMessageRepository;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\n\nclass PlainTextDecorateActivity extends BaseDecorateActivity\n{\n private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;\n\n public function generateTitle(Activity $activity): ?string\n {\n $title = null;\n if ($activity->hasTitle()) {\n $title = $activity->getTitle();\n } elseif ($activity->hasActivityType()) {\n $title = $activity->getActivityType()->getName();\n }\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $title = Str::limit($activity->getDescription(), 250);\n\n break;\n\n case Activity::TYPE_SOFTPHONE_INBOUND:\n if ($title === null) {\n $title = 'Inbound Call';\n }\n\n break;\n\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_CONFERENCE:\n default:\n if ($title === null) {\n $title = 'Outbound Call';\n }\n\n break;\n }\n\n return $title;\n }\n\n public function generateDescription(Activity $activity): ?string\n {\n $description = '';\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $description = $this->getCallHeading($activity);\n\n if ($activity->isTypeSoftphoneInbound()) {\n $description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;\n } else {\n $description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;\n }\n\n $description .= $this->getPreviewUrl($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_CONFERENCE:\n $description = $this->getCallHeading($activity);\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' .\n self::SECTION_SEPARATOR;\n } else {\n $description .= $this->getPreviewUrl($activity);\n }\n\n $description .= self::SECTION_ATTENDEES . ':'\n . PHP_EOL\n . app(FilterJoinedParticipants::class)->toString($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n default:\n if (mb_strlen($activity->getDescription() ?? '') > 250) {\n $description = $activity->getDescription();\n }\n\n break;\n }\n\n return $description;\n }\n\n public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string\n {\n if (empty($existingCrmDescription)) {\n return $jiminnyDescription;\n }\n\n if (! $this->isJiminnyDescription($existingCrmDescription)) {\n return $existingCrmDescription\n . self::SECTION_SEPARATOR\n . str_repeat('-', 50)\n . PHP_EOL\n . $jiminnyDescription;\n }\n\n $existingSections = $this->parseSections($existingCrmDescription);\n $newSections = $this->parseSections($jiminnyDescription);\n\n return $this->mergeSections($newSections, $existingSections);\n }\n\n private function getCallHeading(Activity $activity): string\n {\n $description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;\n $description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;\n\n return $description;\n }\n\n private function getPreviewUrl(Activity $activity): string\n {\n $description = '';\n if ($activity->canReviewActivity()) {\n $playbackUrl = PlaybackUrlBuilder::build($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;\n }\n\n return $description;\n }\n\n private function getActivityNotes(Activity $activity): string\n {\n if ($activity->getNotes()->isEmpty()) {\n return '';\n }\n\n $description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;\n\n foreach ($activity->getNotes() as $note) {\n $time = ($note->getTime() > 3600)\n ? gmdate('H:i:s', (int) $note->getTime())\n : gmdate('i:s', (int) $note->getTime());\n\n $description .= $time . ' ' . $note->getNote() . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getActivityMessages(Activity $activity): string\n {\n return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);\n }\n\n private function getCoachingChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $privateMessages = $messageRepo->getPrivateMessages($activity);\n\n if ($privateMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($privateMessages);\n }\n\n private function getCustomerChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $publicMessages = $messageRepo->getPublicMessages($activity);\n\n if ($publicMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($publicMessages);\n }\n\n private function getMessagesAsString(Collection $messages): string\n {\n $description = '';\n\n /** @var Activity\\Message $message */\n foreach ($messages as $message) {\n $description .= $message->participant->getName()\n . ': '\n . $message->getAttribute('message')\n . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getSummaryField(Activity $activity): string\n {\n if ($activity->getSummary() === null) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();\n }\n\n private function parseSections(string $description): array\n {\n $sections = [\n 'Original Content' => '',\n 'Header' => '',\n ];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sections[$sectionName] = '';\n }\n\n $markerPos = strpos($description, self::JIMINNY_MARKER);\n if ($markerPos !== false && $markerPos > 0) {\n $sections['Original Content'] = trim(substr($description, 0, $markerPos));\n }\n\n $jiminnyStart = $markerPos !== false ? $markerPos : 0;\n $headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);\n\n if ($headerEndPos === false) {\n $sections['Header'] = trim(substr($description, $jiminnyStart));\n\n return $sections;\n }\n\n $sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);\n if ($sectionContent !== null) {\n $sections[$sectionName] = $sectionContent;\n }\n }\n\n return $sections;\n }\n\n private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false\n {\n $firstPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {\n $firstPos = $pos;\n }\n }\n\n return $firstPos;\n }\n\n private function extractSectionContent(\n string $description,\n string $sectionName,\n int $afterPosition = 0\n ): ?string {\n $sectionStart = strpos($description, $sectionName . ':', $afterPosition);\n if ($sectionStart === false) {\n return null;\n }\n\n $contentStart = $sectionStart + strlen($sectionName . ':');\n $nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);\n\n $endPos = strlen($description);\n if ($nextSectionPos !== false) {\n $endPos = $nextSectionPos;\n }\n\n return trim(substr($description, $contentStart, $endPos - $contentStart));\n }\n\n private function findNextSectionPosition(\n string $description,\n int $afterPosition,\n string $currentSection = ''\n ): int|false {\n $nextPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n if ($sectionName === $currentSection) {\n continue;\n }\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {\n $nextPos = $pos;\n }\n }\n\n return $nextPos;\n }\n\n private function mergeSections(array $newSections, array $existingSections): string\n {\n $result = '';\n\n $originalContent = $existingSections['Original Content'] ?? '';\n if (! empty($originalContent)) {\n $result .= $originalContent . self::SECTION_SEPARATOR;\n }\n\n $result .= $newSections['Header'];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $newContent = $newSections[$sectionName] ?? '';\n $existingContent = $existingSections[$sectionName] ?? '';\n\n $mergedContent = $this->mergeSectionContent($newContent, $existingContent);\n if (! empty($mergedContent)) {\n $result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;\n }\n }\n\n return $result;\n }\n\n private function mergeSectionContent(string $newContent, string $existingContent): string\n {\n if (! empty($existingContent)) {\n return $existingContent;\n }\n\n return $newContent;\n }\n}","depth":4,"bounds":{"left":0.26666668,"top":0.18666667,"width":0.6166667,"height":0.81333333},"on_screen":true,"value":"<?php\n\ndeclare(strict_types=1);\n\nnamespace Jiminny\\Services\\Crm\\DecorateActivity;\n\nuse Illuminate\\Support\\Collection;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Repositories\\ActivityMessageRepository;\nuse Jiminny\\Services\\Crm\\Helpers\\FilterJoinedParticipants;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\n\nclass PlainTextDecorateActivity extends BaseDecorateActivity\n{\n private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;\n\n public function generateTitle(Activity $activity): ?string\n {\n $title = null;\n if ($activity->hasTitle()) {\n $title = $activity->getTitle();\n } elseif ($activity->hasActivityType()) {\n $title = $activity->getActivityType()->getName();\n }\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $title = Str::limit($activity->getDescription(), 250);\n\n break;\n\n case Activity::TYPE_SOFTPHONE_INBOUND:\n if ($title === null) {\n $title = 'Inbound Call';\n }\n\n break;\n\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_CONFERENCE:\n default:\n if ($title === null) {\n $title = 'Outbound Call';\n }\n\n break;\n }\n\n return $title;\n }\n\n public function generateDescription(Activity $activity): ?string\n {\n $description = '';\n\n switch ($activity->getCrmType()) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $description = $this->getCallHeading($activity);\n\n if ($activity->isTypeSoftphoneInbound()) {\n $description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;\n } else {\n $description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;\n }\n\n $description .= $this->getPreviewUrl($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_CONFERENCE:\n $description = $this->getCallHeading($activity);\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= 'Notetaker did not join due to recording consent not being provided by attendees' .\n self::SECTION_SEPARATOR;\n } else {\n $description .= $this->getPreviewUrl($activity);\n }\n\n $description .= self::SECTION_ATTENDEES . ':'\n . PHP_EOL\n . app(FilterJoinedParticipants::class)->toString($activity);\n\n $description .= $this->getActivityNotes($activity);\n $description .= $this->getActivityMessages($activity);\n $description .= $this->getSummaryField($activity);\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n default:\n if (mb_strlen($activity->getDescription() ?? '') > 250) {\n $description = $activity->getDescription();\n }\n\n break;\n }\n\n return $description;\n }\n\n public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string\n {\n if (empty($existingCrmDescription)) {\n return $jiminnyDescription;\n }\n\n if (! $this->isJiminnyDescription($existingCrmDescription)) {\n return $existingCrmDescription\n . self::SECTION_SEPARATOR\n . str_repeat('-', 50)\n . PHP_EOL\n . $jiminnyDescription;\n }\n\n $existingSections = $this->parseSections($existingCrmDescription);\n $newSections = $this->parseSections($jiminnyDescription);\n\n return $this->mergeSections($newSections, $existingSections);\n }\n\n private function getCallHeading(Activity $activity): string\n {\n $description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;\n $description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;\n\n return $description;\n }\n\n private function getPreviewUrl(Activity $activity): string\n {\n $description = '';\n if ($activity->canReviewActivity()) {\n $playbackUrl = PlaybackUrlBuilder::build($activity);\n $description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;\n }\n\n return $description;\n }\n\n private function getActivityNotes(Activity $activity): string\n {\n if ($activity->getNotes()->isEmpty()) {\n return '';\n }\n\n $description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;\n\n foreach ($activity->getNotes() as $note) {\n $time = ($note->getTime() > 3600)\n ? gmdate('H:i:s', (int) $note->getTime())\n : gmdate('i:s', (int) $note->getTime());\n\n $description .= $time . ' ' . $note->getNote() . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getActivityMessages(Activity $activity): string\n {\n return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);\n }\n\n private function getCoachingChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $privateMessages = $messageRepo->getPrivateMessages($activity);\n\n if ($privateMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($privateMessages);\n }\n\n private function getCustomerChat(Activity $activity): string\n {\n $messageRepo = app(ActivityMessageRepository::class);\n $publicMessages = $messageRepo->getPublicMessages($activity);\n\n if ($publicMessages->isEmpty()) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL\n . $this->getMessagesAsString($publicMessages);\n }\n\n private function getMessagesAsString(Collection $messages): string\n {\n $description = '';\n\n /** @var Activity\\Message $message */\n foreach ($messages as $message) {\n $description .= $message->participant->getName()\n . ': '\n . $message->getAttribute('message')\n . PHP_EOL;\n }\n\n return $description;\n }\n\n private function getSummaryField(Activity $activity): string\n {\n if ($activity->getSummary() === null) {\n return '';\n }\n\n return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();\n }\n\n private function parseSections(string $description): array\n {\n $sections = [\n 'Original Content' => '',\n 'Header' => '',\n ];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sections[$sectionName] = '';\n }\n\n $markerPos = strpos($description, self::JIMINNY_MARKER);\n if ($markerPos !== false && $markerPos > 0) {\n $sections['Original Content'] = trim(substr($description, 0, $markerPos));\n }\n\n $jiminnyStart = $markerPos !== false ? $markerPos : 0;\n $headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);\n\n if ($headerEndPos === false) {\n $sections['Header'] = trim(substr($description, $jiminnyStart));\n\n return $sections;\n }\n\n $sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);\n if ($sectionContent !== null) {\n $sections[$sectionName] = $sectionContent;\n }\n }\n\n return $sections;\n }\n\n private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false\n {\n $firstPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {\n $firstPos = $pos;\n }\n }\n\n return $firstPos;\n }\n\n private function extractSectionContent(\n string $description,\n string $sectionName,\n int $afterPosition = 0\n ): ?string {\n $sectionStart = strpos($description, $sectionName . ':', $afterPosition);\n if ($sectionStart === false) {\n return null;\n }\n\n $contentStart = $sectionStart + strlen($sectionName . ':');\n $nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);\n\n $endPos = strlen($description);\n if ($nextSectionPos !== false) {\n $endPos = $nextSectionPos;\n }\n\n return trim(substr($description, $contentStart, $endPos - $contentStart));\n }\n\n private function findNextSectionPosition(\n string $description,\n int $afterPosition,\n string $currentSection = ''\n ): int|false {\n $nextPos = false;\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n if ($sectionName === $currentSection) {\n continue;\n }\n $pos = strpos($description, $sectionName . ':', $afterPosition);\n if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {\n $nextPos = $pos;\n }\n }\n\n return $nextPos;\n }\n\n private function mergeSections(array $newSections, array $existingSections): string\n {\n $result = '';\n\n $originalContent = $existingSections['Original Content'] ?? '';\n if (! empty($originalContent)) {\n $result .= $originalContent . self::SECTION_SEPARATOR;\n }\n\n $result .= $newSections['Header'];\n\n foreach (self::SECTION_HEADERS as $sectionName) {\n $newContent = $newSections[$sectionName] ?? '';\n $existingContent = $existingSections[$sectionName] ?? '';\n\n $mergedContent = $this->mergeSectionContent($newContent, $existingContent);\n if (! empty($mergedContent)) {\n $result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;\n }\n }\n\n return $result;\n }\n\n private function mergeSectionContent(string $newContent, string $existingContent): string\n {\n if (! empty($existingContent)) {\n return $existingContent;\n }\n\n return $newContent;\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
5189083665197206925
|
2074642764900996701
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
1
2
Previous Highlighted Error
Next Highlighted Error
<?php
declare(strict_types=1);
namespace Jiminny\Services\Crm\DecorateActivity;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Jiminny\Models\Activity;
use Jiminny\Repositories\ActivityMessageRepository;
use Jiminny\Services\Crm\Helpers\FilterJoinedParticipants;
use Jiminny\Utils\PlaybackUrlBuilder;
class PlainTextDecorateActivity extends BaseDecorateActivity
{
private const string SECTION_SEPARATOR = PHP_EOL . PHP_EOL;
public function generateTitle(Activity $activity): ?string
{
$title = null;
if ($activity->hasTitle()) {
$title = $activity->getTitle();
} elseif ($activity->hasActivityType()) {
$title = $activity->getActivityType()->getName();
}
switch ($activity->getCrmType()) {
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
$title = Str::limit($activity->getDescription(), 250);
break;
case Activity::TYPE_SOFTPHONE_INBOUND:
if ($title === null) {
$title = 'Inbound Call';
}
break;
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_CONFERENCE:
default:
if ($title === null) {
$title = 'Outbound Call';
}
break;
}
return $title;
}
public function generateDescription(Activity $activity): ?string
{
$description = '';
switch ($activity->getCrmType()) {
case Activity::TYPE_SOFTPHONE:
case Activity::TYPE_SOFTPHONE_INBOUND:
$description = $this->getCallHeading($activity);
if ($activity->isTypeSoftphoneInbound()) {
$description .= 'Caller: ' . $activity->from?->phone_number . self::SECTION_SEPARATOR;
} else {
$description .= 'Dialed: ' . $activity->to?->phone_number . self::SECTION_SEPARATOR;
}
$description .= $this->getPreviewUrl($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_CONFERENCE:
$description = $this->getCallHeading($activity);
if ($activity->hasReasonCodeBotKicked()) {
$description .= 'Notetaker removed from this meeting' . self::SECTION_SEPARATOR;
} elseif ($activity->hasReasonCodeNotCompliant()) {
$description .= 'Notetaker did not join due to recording consent not being provided by attendees' .
self::SECTION_SEPARATOR;
} else {
$description .= $this->getPreviewUrl($activity);
}
$description .= self::SECTION_ATTENDEES . ':'
. PHP_EOL
. app(FilterJoinedParticipants::class)->toString($activity);
$description .= $this->getActivityNotes($activity);
$description .= $this->getActivityMessages($activity);
$description .= $this->getSummaryField($activity);
break;
case Activity::TYPE_SMS_INBOUND:
case Activity::TYPE_SMS_OUTBOUND:
default:
if (mb_strlen($activity->getDescription() ?? '') > 250) {
$description = $activity->getDescription();
}
break;
}
return $description;
}
public function mergeDescriptions(string $jiminnyDescription, ?string $existingCrmDescription): string
{
if (empty($existingCrmDescription)) {
return $jiminnyDescription;
}
if (! $this->isJiminnyDescription($existingCrmDescription)) {
return $existingCrmDescription
. self::SECTION_SEPARATOR
. str_repeat('-', 50)
. PHP_EOL
. $jiminnyDescription;
}
$existingSections = $this->parseSections($existingCrmDescription);
$newSections = $this->parseSections($jiminnyDescription);
return $this->mergeSections($newSections, $existingSections);
}
private function getCallHeading(Activity $activity): string
{
$description = self::JIMINNY_MARKER . self::SECTION_SEPARATOR;
$description .= 'Call Duration: ' . $activity->getAttribute('duration_for_humans') . self::SECTION_SEPARATOR;
return $description;
}
private function getPreviewUrl(Activity $activity): string
{
$description = '';
if ($activity->canReviewActivity()) {
$playbackUrl = PlaybackUrlBuilder::build($activity);
$description .= 'Review Activity:' . PHP_EOL . $playbackUrl . self::SECTION_SEPARATOR;
}
return $description;
}
private function getActivityNotes(Activity $activity): string
{
if ($activity->getNotes()->isEmpty()) {
return '';
}
$description = self::SECTION_SEPARATOR . self::SECTION_NOTES . ':' . PHP_EOL;
foreach ($activity->getNotes() as $note) {
$time = ($note->getTime() > 3600)
? gmdate('H:i:s', (int) $note->getTime())
: gmdate('i:s', (int) $note->getTime());
$description .= $time . ' ' . $note->getNote() . PHP_EOL;
}
return $description;
}
private function getActivityMessages(Activity $activity): string
{
return $this->getCoachingChat($activity) . $this->getCustomerChat($activity);
}
private function getCoachingChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$privateMessages = $messageRepo->getPrivateMessages($activity);
if ($privateMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_COACHING_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($privateMessages);
}
private function getCustomerChat(Activity $activity): string
{
$messageRepo = app(ActivityMessageRepository::class);
$publicMessages = $messageRepo->getPublicMessages($activity);
if ($publicMessages->isEmpty()) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_CUSTOMER_CHAT . ':' . PHP_EOL
. $this->getMessagesAsString($publicMessages);
}
private function getMessagesAsString(Collection $messages): string
{
$description = '';
/** @var Activity\Message $message */
foreach ($messages as $message) {
$description .= $message->participant->getName()
. ': '
. $message->getAttribute('message')
. PHP_EOL;
}
return $description;
}
private function getSummaryField(Activity $activity): string
{
if ($activity->getSummary() === null) {
return '';
}
return self::SECTION_SEPARATOR . self::SECTION_SUMMARY . ':' . PHP_EOL . $activity->getSummary();
}
private function parseSections(string $description): array
{
$sections = [
'Original Content' => '',
'Header' => '',
];
foreach (self::SECTION_HEADERS as $sectionName) {
$sections[$sectionName] = '';
}
$markerPos = strpos($description, self::JIMINNY_MARKER);
if ($markerPos !== false && $markerPos > 0) {
$sections['Original Content'] = trim(substr($description, 0, $markerPos));
}
$jiminnyStart = $markerPos !== false ? $markerPos : 0;
$headerEndPos = $this->findFirstSectionPosition($description, $jiminnyStart);
if ($headerEndPos === false) {
$sections['Header'] = trim(substr($description, $jiminnyStart));
return $sections;
}
$sections['Header'] = trim(substr($description, $jiminnyStart, $headerEndPos - $jiminnyStart));
foreach (self::SECTION_HEADERS as $sectionName) {
$sectionContent = $this->extractSectionContent($description, $sectionName, $jiminnyStart);
if ($sectionContent !== null) {
$sections[$sectionName] = $sectionContent;
}
}
return $sections;
}
private function findFirstSectionPosition(string $description, int $afterPosition = 0): int|false
{
$firstPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($firstPos === false || $pos < $firstPos)) {
$firstPos = $pos;
}
}
return $firstPos;
}
private function extractSectionContent(
string $description,
string $sectionName,
int $afterPosition = 0
): ?string {
$sectionStart = strpos($description, $sectionName . ':', $afterPosition);
if ($sectionStart === false) {
return null;
}
$contentStart = $sectionStart + strlen($sectionName . ':');
$nextSectionPos = $this->findNextSectionPosition($description, $contentStart, $sectionName);
$endPos = strlen($description);
if ($nextSectionPos !== false) {
$endPos = $nextSectionPos;
}
return trim(substr($description, $contentStart, $endPos - $contentStart));
}
private function findNextSectionPosition(
string $description,
int $afterPosition,
string $currentSection = ''
): int|false {
$nextPos = false;
foreach (self::SECTION_HEADERS as $sectionName) {
if ($sectionName === $currentSection) {
continue;
}
$pos = strpos($description, $sectionName . ':', $afterPosition);
if ($pos !== false && ($nextPos === false || $pos < $nextPos)) {
$nextPos = $pos;
}
}
return $nextPos;
}
private function mergeSections(array $newSections, array $existingSections): string
{
$result = '';
$originalContent = $existingSections['Original Content'] ?? '';
if (! empty($originalContent)) {
$result .= $originalContent . self::SECTION_SEPARATOR;
}
$result .= $newSections['Header'];
foreach (self::SECTION_HEADERS as $sectionName) {
$newContent = $newSections[$sectionName] ?? '';
$existingContent = $existingSections[$sectionName] ?? '';
$mergedContent = $this->mergeSectionContent($newContent, $existingContent);
if (! empty($mergedContent)) {
$result .= self::SECTION_SEPARATOR . $sectionName . ':' . PHP_EOL . $mergedContent;
}
}
return $result;
}
private function mergeSectionContent(string $newContent, string $existingContent): string
{
if (! empty($existingContent)) {
return $existingContent;
}
return $newContent;
}
}
Execute
Explain Plan
Browse Query History
View Parameters
Open Query Execution Settings…
In-Editor Results
Tx: Auto
Cancel Running Statements
Playground
jiminny
Sync Changes
Hide This Notification
Code changed:
Hide
8
1
3
4
Previous Highlighted Error
Next Highlighted Error
SELECT
# DISTINCT
opp.id as opp_id, opp.uuid, opp.name,
# COUNT(dr.id) AS deal_risk_count,
# dr.id,
# cfd.value,
usr.name AS owner_name,
opp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,
usr.uuid as owner_uuid,
usr.photo_path as owner_photo,
usr.id AS owner_id,
jt.name as owner_job,
opp.stage_id, opp.stage_updated_at,
acc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,
rt.business_process_id AS pipeline_id
FROM opportunities opp
LEFT JOIN record_types rt ON opp.record_type_id = rt.id
LEFT JOIN users usr ON opp.user_id = usr.id
LEFT JOIN accounts acc ON opp.account_id = acc.id
LEFT JOIN job_titles jt ON usr.job_title_id = jt.id
# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id
# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id
LEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)
WHERE opp.user_id IS NOT NULL
AND opp.deleted_at IS NULL
AND opp.is_closed = 1
AND opp.is_won = 0
AND opp.close_date >= '2024-01-01 00:00:00'
AND opp.close_date <= '2024-12-31 23:59:59'
AND usr.team_id = 1
# and opp.id = 4823179
GROUP BY opp.id, cfd.value
ORDER BY
# cfd.value
CAST(cfd.value AS UNSIGNED)
# owner_name
# (SELECT COUNT(dr.id)
# FROM deal_risks dr
# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
# WHERE dr.opportunity_id = opp.id
# AND dr.is_active = 1
# AND gdrt.is_enabled = 1)
DESC
LIMIT 25
# OFFSET 0
;
select * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';
select * from crm_layout_entities where crm_layout_id = 1493;
SELECT * FROM opportunities WHERE id = 4584045;
SELECT * FROM crm_field_data WHERE object_id = 4584045;
SELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'
SELECT
opp.id as opportunity_id,
opp.name,
COUNT(dr.id) as risk_count
FROM opportunities opp
LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id
WHERE opp.id IN (
6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788
)
GROUP BY opp.id;
SELECT COUNT(dr.id)
FROM deal_risks dr
JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
WHERE dr.opportunity_id = 5563344
# AND gdrt.group_id = usr.group_id
EXPLAIN SELECT
opp.id as opp_id, opp.uuid, opp.name,
cfd.value,
cfv.sequence,
cfv.value,
# MAX(cfd.value) AS max_cfd_value,
usr.name AS owner_name,
opp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,
usr.uuid as owner_uuid,
usr.photo_path as owner_photo,
usr.id AS owner_id,
jt.name as owner_job,
opp.stage_id, opp.stage_updated_at,
acc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,
rt.business_process_id AS pipeline_id
FROM opportunities opp
LEFT JOIN record_types rt ON opp.record_type_id = rt.id
LEFT JOIN users usr ON opp.user_id = usr.id
LEFT JOIN accounts acc ON opp.account_id = acc.id
LEFT JOIN job_titles jt ON usr.job_title_id = jt.id
LEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id
FROM crm_field_data sub_cfd
WHERE sub_cfd.object_id = opp.id
AND sub_cfd.crm_field_id = 66810
ORDER BY sub_cfd.updated_at DESC
LIMIT 1)
LEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value
WHERE opp.user_id IS NOT NULL
AND opp.deleted_at IS NULL
AND opp.is_closed = 0
# AND opp.is_closed = 1
# AND opp.is_won = 0
AND opp.close_date >= '2024-01-01 00:00:00'
AND opp.close_date <= '2024-12-31 23:59:59'
AND usr.team_id = 1
# and opp.id = 4823179
# GROUP BY opp.id
ORDER BY
cfv.sequence DESC,
# opp.name
# owner_name
cfd.value
# CAST(cfd.value AS UNSIGNED)
# CAST(MAX(cfd.value) AS UNSIGNED)
# (SELECT COUNT(dr.id)
# FROM deal_risks dr
# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id
# WHERE dr.opportunity_id = opp.id
# AND dr.is_active = 1 AND gdrt.is_enabled = 1)
DESC
# LIMIT 75, 25
;
SELECT * FROM crm_field_values WHERE crm_field_id = 66810;
SELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at
FROM crm_fields f
INNER JOIN crm_field_data fd ON fd.crm_field_id = f.id
WHERE (f.crm_configuration_id = 1)
AND (f.object_type = 'opportunity')
# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))
AND (fd.object_id IN (4158460))
AND (f.crm_provider_id IN ('ForecastCategoryName'))
ORDER BY fd.object_id ASC, fd.updated_at DESC;
select * from crm_layouts where crm_configuration_id = 1;
select cf.* from crm_fields cf
join crm_layout_entities cle on cf.id = cle.crm_field_id
where crm_layout_id = 1493;
SELECT * FROM opportunities WHERE id = 4158460;
SELECT * FROM crm_field_data WHERE object_id = 4158460;
select * from users where team_id = 1;
SELECT * FROM users WHERE id = 7160;
select * from role_user where user_id = 3248;
select * from crm_field_values where crm_field_id = 66824;
SELECT * FROM users WHERE id = 23470;
select * from crm_configurations;
# [PASSWORD_DOTS]
select * from teams where id = 1038; # 23521, 966
select * from users where team_id = 1038;
select * from crm_configurations where id = 966;
SELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762
SELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764
SELECT * FROM participants WHERE activity_id = 54965764;
SELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075
SELECT * FROM participants WHERE activity_id = 54964075;
select f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id
where tf.team_id = 1038;
# [PASSWORD_DOTS] PD [PASSWORD_DOTS]
select * from teams where id = 1029;
SELECT * FROM crm_configurations WHERE id = 957;
SELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423
SELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433
SELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421
# [PASSWORD_DOTS] Close [PASSWORD_DOTS]
select * from teams where id = 1031;
SELECT * FROM crm_configurations WHERE id = 959;
SELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517
SELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433
SELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009
# [PASSWORD_DOTS] SF [PASSWORD_DOTS]
SELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;
select * from roles;
select * from permissions;
select * from permission_role where permission_id = 136;
select * from migrations order by id desc;
select * from teams where id IN (1, 1037);
select * from crm_layouts where crm_configuration_id = 1;
SELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;
SELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);
select * from features;
select * from team_features where feature_id = 33;
select * from opportunities;
select * from teams;
SELECT
CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,
u.email,
sa.*,
t.owner_id FROM social_accounts sa
JOIN users u on u.id = sa.sociable_id
JOIN teams t on t.id = u.team_id
WHERE u.team_id = 1052 and sa.provider = 'hubspot';
SELECT * FROM accounts where id = 11512582;
select * from activities where account_id = 11512582;
Project
Project
New File or Directory…
Expand Selected
Collapse All
Options
Hide...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45228
|
1621
|
57
|
2026-05-14T14:26:00.628604+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768760628_m1.jpg...
|
PhpStorm
|
faVsco.js – PlainTextDecorateActivity.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7759925082980464890
|
-7195274138718106135
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
PhpStorm•FileFV faVsco.jsvEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl100% (C47#12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTestv* :8• Thu 14 May 17:26:00QProjectvReportController.php© AutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.phpv • DecorateActivityUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© BaseDecorateActivity.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© Service.php© PlainTextDecorateActivity.pl> DummyT LogActivityTrait.php© PlainTextDecorateActivity.phpActivityPlaybookTrait.phpCrmHelperRepository.phpD Helpers© AccountController.phpT IntegrationAppTrait.php.env.staging18E.env© DetachActivityObject.phpT ActivityPlaybookTrait.php© Arraylterator.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.phpHubspotPaginationService.phpT ConnectionStateTrait.php© CrmHelperRepository.php© HandleHubspotRateLimit.php6© FilterJoinedParticipants.phpUSe+ OpportunitySyncableFieldsTv D HubspotD AccountSyncStrategy1314 @15classPlainTextDecorateActivity extends BaseDecorateActivity> OJ Actions|14 usages• ContactSyncStrategy16> ODTOprivate const string SECTION_SEPARATOR = PHP_EOL - PHP_EOL;17› D Fields18 đpublic function generateTitle(Activity $activity): ?string> • Journal19{D Metadata20|~ D OpportunitySyncStrategy21> D Concerns22© HubspotLastModifiedByF23© HubspotLastModifiedCre24$title = null;if ($activity->hasTitle()) {$title = $activity->getTitle);} elseif ($activity->hasActivityTypeO)) {$title = $activity->getActivityType()->getName();© HubspotLastModifiedCre25© HubspotLastModifiedOpe26© HubspotLastModifiedSyn27© HubspotSingleSyncStrate28© HubspotSyncStrategyBa:29© HubspotWebhookBatchS30v C Paginationswitch (Sactivity->getCrmType)) {case Activity:: TYPE_SMS_INBOUND:case Activity:: TYPE_SMS_OUTBOUND:Stitle = Str::limit($activity->getDescription®,limit: 250);31© HubspotPaginationServic32break;© PaginationConfig.php33© PaginationState.php34case Activity:: TYPE_SOFTPHONE_INBOUND:> C ProspectSearchStrategy35if (Stitle === null) 1> D Redis36Stitle = 'Inhound Call';v M ContiooTraiteWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)41 ×2 ^=custom.log=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]D — 228229230231232233 V234Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !M crm_layout_eilIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASEWHEN,id FROM social,1 on V.id = sa.:1..n<->1: on t.Lid = 1052 andIMaccounts wheiactivitiesW Windsurf Teams18:21UTF-8Co 4 spaces...
|
45226
|
NULL
|
NULL
|
NULL
|
|
45227
|
1622
|
30
|
2026-05-14T14:26:00.173584+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768760173_m2.jpg...
|
PhpStorm
|
faVsco.js – PlainTextDecorateActivity.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterdav at 13.34Yesterday at 13:33Yesterday at 13.32Yesterdav at 13:32Yesterday at 13:31Yesterdav at 13:30Yesterday at 13:30Yesterday at 13.29Yesterdav at 13:28resterday at 15.21Yesterdav at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:23Yesterdav at 13:25Yesterday at 13.24Yesterdav at 13:23Yesterday at 13:22Yesterday at 13:21Voctordau at 12:20lYesterday at 13.19Yesterdav at 13:19Yesterdav at 13:18Yesterday at 13:18Yesterday at 13:17Yesterday at 13:17Yesterday at 13:16Yesterday at 13.16Yecterdav at 12:15Yesterdav at 13:15Yesterday at 13-14Yesterdav at 13:14Yesterday at 13:13Yesterday at 13:12Yecterdav at 12:12Yesterday at 13.11Yesterdav at 13:10Yesterday at 13:10Yesterday at 13:09Yoctorday at 12:09Yesterdav at 13:08Yesterday at 13.0Yecterdav at 12:07Yesterday at 13:06Yesterdav at 13:0529 K:MPEG-4 movie6 KB12 KBMDEG-A movieMPEG-4 movieMPEG-4 movie7 KBI8 KB37 KB10 K:MPEG-4 movie7 KBMDEG-A movie8 KEMPEG-4 movie8 KBI72 KB14 KB13 KEMPEG-4 movieMPEG-4 movie9 KBMDECA mavie18 KB12 KB|MPEG-4 movie10 KRI16 KB6 KBMPEG-1 movidMPEG-4 movie6KEMPEG-4 movie12 KBMPEG-4 movie23 KB8 K:MPEG-4 movie6 KB6 KB11 KBMDSG.A movicMPEG-4 movie11 KE20 KBMPEG-4 movieMPEG-4 movie34 KB10 K:MPEG-4 movie7 Kp5 KB11 KBMDSG.A movidMPEG-4 movie26KPMDEG-A movie111 KB MPEG-4 movie102 KB88 KB59 KB98 KB97 KBMPEG-4 movieMDEC.A moviaMPEG-4 movieRAKRMDEG-A movie44 KB93 KBMPEG-4 movie78 K:MPEG-A movid50 KBMPEG-4 movie58 KB27 KB7 KPMPEG-4 movieMDEG.A movio12 KB32 KBMPEG-4 movie17 Kг19 KBMDEC A movid32 KB10K8MPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esviconfig.vmlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.56 GB availabld• Inu 14 May 1/.20:0%Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-7364587492645335143
|
NULL
|
click
|
ocr
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterdav at 13.34Yesterday at 13:33Yesterday at 13.32Yesterdav at 13:32Yesterday at 13:31Yesterdav at 13:30Yesterday at 13:30Yesterday at 13.29Yesterdav at 13:28resterday at 15.21Yesterdav at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:23Yesterdav at 13:25Yesterday at 13.24Yesterdav at 13:23Yesterday at 13:22Yesterday at 13:21Voctordau at 12:20lYesterday at 13.19Yesterdav at 13:19Yesterdav at 13:18Yesterday at 13:18Yesterday at 13:17Yesterday at 13:17Yesterday at 13:16Yesterday at 13.16Yecterdav at 12:15Yesterdav at 13:15Yesterday at 13-14Yesterdav at 13:14Yesterday at 13:13Yesterday at 13:12Yecterdav at 12:12Yesterday at 13.11Yesterdav at 13:10Yesterday at 13:10Yesterday at 13:09Yoctorday at 12:09Yesterdav at 13:08Yesterday at 13.0Yecterdav at 12:07Yesterday at 13:06Yesterdav at 13:0529 K:MPEG-4 movie6 KB12 KBMDEG-A movieMPEG-4 movieMPEG-4 movie7 KBI8 KB37 KB10 K:MPEG-4 movie7 KBMDEG-A movie8 KEMPEG-4 movie8 KBI72 KB14 KB13 KEMPEG-4 movieMPEG-4 movie9 KBMDECA mavie18 KB12 KB|MPEG-4 movie10 KRI16 KB6 KBMPEG-1 movidMPEG-4 movie6KEMPEG-4 movie12 KBMPEG-4 movie23 KB8 K:MPEG-4 movie6 KB6 KB11 KBMDSG.A movicMPEG-4 movie11 KE20 KBMPEG-4 movieMPEG-4 movie34 KB10 K:MPEG-4 movie7 Kp5 KB11 KBMDSG.A movidMPEG-4 movie26KPMDEG-A movie111 KB MPEG-4 movie102 KB88 KB59 KB98 KB97 KBMPEG-4 movieMDEC.A moviaMPEG-4 movieRAKRMDEG-A movie44 KB93 KBMPEG-4 movie78 K:MPEG-A movid50 KBMPEG-4 movie58 KB27 KB7 KPMPEG-4 movieMDEG.A movio12 KB32 KBMPEG-4 movie17 Kг19 KBMDEC A movid32 KB10K8MPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esviconfig.vmlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.56 GB availabld• Inu 14 May 1/.20:0%Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45321
|
1624
|
32
|
2026-05-14T14:30:52.931620+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769052931_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:33Yesterday at 13:32Yesterdav at 13:37Yesterday at 13:31Yesterday at 13.30Yesterday at 13:30Yesterday at 13:29resterday at 15.4gYesterdav at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:26resterday at 13-4oYesterdav at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13:22Yesterday at 13.21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:17Yesterday at 13:16Yesterday at 13-10Yesterdav at 13:15Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:12Yesterday at 13.12Yesterdav at 13:12Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:10Yesterday at 13:09Yesterday at 13.09Yocterdav at 12:081Yesterday at 13:08Yesterdav at 13:071S K:MPEG-4 movie16 KBMPEG-4 movie17 KB29 Kb6 KEMPEG-4 movieMPEG-4 movie12 KB9 KBMPE0"4 movie7 K:8 KBMPEG-4 movie37 KB10 KbMP2G-4 movie7 KBMPEG-4 movie8 KBMPEG-4 movie8 K:72 KBMPEG-4 movie14 KB13 K:O KRIMPEG-4 movieMPEG-4 movie18 KB12 KBMPEG-4 movie10 KEMPEG-4 movie16 KB MPEG-4 movie6 KB6 KB1MPEG-4 movie12 KBMPEG-4 movie23 KB8 KBMPEG-4 movie6KEMPEG-4 movie6 KBMPEG-4 movie11 KB11 K:MPEG-4 movie20 KBMPEG-4 movie10 KBMPEG-4 movie7 KPMPEG-4 movie5 KBMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 KBMPEG-4 movie102 KB88 KBMPEG-4 movieKO KRMDEG-A movie98 KBMPEG-4 movie97 KBMPEG-4 movie66 K:MPEG-A movie44 KB MPEG-4 movie93 KB78 KB50 kpMPEG-4 movieMPEG-4 movie58 KB27 KBMPEG-4 movie7KP12 KBMPEG-4 movie32 KB17 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network_• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.51 G8 availabld• Inu 14 May 1/.30:04Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-4257557048185595902
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:33Yesterday at 13:32Yesterdav at 13:37Yesterday at 13:31Yesterday at 13.30Yesterday at 13:30Yesterday at 13:29resterday at 15.4gYesterdav at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:26resterday at 13-4oYesterdav at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13:22Yesterday at 13.21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:17Yesterday at 13:16Yesterday at 13-10Yesterdav at 13:15Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:12Yesterday at 13.12Yesterdav at 13:12Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:10Yesterday at 13:09Yesterday at 13.09Yocterdav at 12:081Yesterday at 13:08Yesterdav at 13:071S K:MPEG-4 movie16 KBMPEG-4 movie17 KB29 Kb6 KEMPEG-4 movieMPEG-4 movie12 KB9 KBMPE0"4 movie7 K:8 KBMPEG-4 movie37 KB10 KbMP2G-4 movie7 KBMPEG-4 movie8 KBMPEG-4 movie8 K:72 KBMPEG-4 movie14 KB13 K:O KRIMPEG-4 movieMPEG-4 movie18 KB12 KBMPEG-4 movie10 KEMPEG-4 movie16 KB MPEG-4 movie6 KB6 KB1MPEG-4 movie12 KBMPEG-4 movie23 KB8 KBMPEG-4 movie6KEMPEG-4 movie6 KBMPEG-4 movie11 KB11 K:MPEG-4 movie20 KBMPEG-4 movie10 KBMPEG-4 movie7 KPMPEG-4 movie5 KBMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 KBMPEG-4 movie102 KB88 KBMPEG-4 movieKO KRMDEG-A movie98 KBMPEG-4 movie97 KBMPEG-4 movie66 K:MPEG-A movie44 KB MPEG-4 movie93 KB78 KB50 kpMPEG-4 movieMPEG-4 movie58 KB27 KBMPEG-4 movie7KP12 KBMPEG-4 movie32 KB17 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network_• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.51 G8 availabld• Inu 14 May 1/.30:04Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45318
|
NULL
|
NULL
|
NULL
|
|
45316
|
1624
|
30
|
2026-05-14T14:30:39.607347+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769039607_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:33Yesterday at 13:32Yesterdav at 13:37Yesterday at 13:31Yesterday at 13.30Yesterday at 13:30Yesterday at 13:29resterday at 15.4gYesterdav at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:26resterday at 13-4oYesterdav at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13:22Yesterday at 13.21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:17Yesterday at 13:16Yesterday at 13-10Yesterdav at 13:15Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:12Yesterday at 13.12Yesterdav at 13:12Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:10Yesterday at 13:09Yesterday at 13.09Yocterdav at 12:081Yesterday at 13:08Yesterdav at 13:071S K:MPEG-4 movie16 KBMPEG-4 movie17 KB29 Kb6 KEMPEG-4 movieMPEG-4 movie12 KB9 KBMPE0"4 movie7 K:8 KBMPEG-4 movie37 KB10 KbMP2G-4 movie7 KBMPEG-4 movie8 KBMPEG-4 movie8 K:72 KBMPEG-4 movie14 KB13 K:O KRIMPEG-4 movieMPEG-4 movie18 KB12 KBMPEG-4 movie10 KEMPEG-4 movie16 KB MPEG-4 movie6 KB6 KB1MPEG-4 movie12 KBMPEG-4 movie23 KB8 KBMPEG-4 movie6KEMPEG-4 movie6 KBMPEG-4 movie11 KB11 K:MPEG-4 movie20 KBMPEG-4 movie10 KBMPEG-4 movie7 KPMPEG-4 movie5 KBMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 KBMPEG-4 movie102 KB88 KBMPEG-4 movieKO KRMDEG-A movie98 KBMPEG-4 movie97 KBMPEG-4 movie66 K:MPEG-A movie44 KB MPEG-4 movie93 KB78 KB50 kpMPEG-4 movieMPEG-4 movie58 KB27 KBMPEG-4 movie7KP12 KBMPEG-4 movie32 KB17 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network_• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.52 GB availabld• Inu 14 May 1/•30.34Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
8979399093737450201
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:33Yesterday at 13:32Yesterdav at 13:37Yesterday at 13:31Yesterday at 13.30Yesterday at 13:30Yesterday at 13:29resterday at 15.4gYesterdav at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:26resterday at 13-4oYesterdav at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13:22Yesterday at 13.21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:17Yesterday at 13:16Yesterday at 13-10Yesterdav at 13:15Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:12Yesterday at 13.12Yesterdav at 13:12Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:10Yesterday at 13:09Yesterday at 13.09Yocterdav at 12:081Yesterday at 13:08Yesterdav at 13:071S K:MPEG-4 movie16 KBMPEG-4 movie17 KB29 Kb6 KEMPEG-4 movieMPEG-4 movie12 KB9 KBMPE0"4 movie7 K:8 KBMPEG-4 movie37 KB10 KbMP2G-4 movie7 KBMPEG-4 movie8 KBMPEG-4 movie8 K:72 KBMPEG-4 movie14 KB13 K:O KRIMPEG-4 movieMPEG-4 movie18 KB12 KBMPEG-4 movie10 KEMPEG-4 movie16 KB MPEG-4 movie6 KB6 KB1MPEG-4 movie12 KBMPEG-4 movie23 KB8 KBMPEG-4 movie6KEMPEG-4 movie6 KBMPEG-4 movie11 KB11 K:MPEG-4 movie20 KBMPEG-4 movie10 KBMPEG-4 movie7 KPMPEG-4 movie5 KBMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 KBMPEG-4 movie102 KB88 KBMPEG-4 movieKO KRMDEG-A movie98 KBMPEG-4 movie97 KBMPEG-4 movie66 K:MPEG-A movie44 KB MPEG-4 movie93 KB78 KB50 kpMPEG-4 movieMPEG-4 movie58 KB27 KBMPEG-4 movie7KP12 KBMPEG-4 movie32 KB17 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network_• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.52 GB availabld• Inu 14 May 1/•30.34Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45313
|
NULL
|
NULL
|
NULL
|
|
45315
|
1623
|
42
|
2026-05-14T14:30:41.034184+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769041034_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.73194444,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"bounds":{"left":0.75277776,"top":0.3122222,"width":0.023611112,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.78055555,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
45314
|
NULL
|
NULL
|
NULL
|
|
45314
|
1623
|
41
|
2026-05-14T14:30:38.020115+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769038020_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit K vProject vReportController.php© AutomatedReportGenerated.phpablHandleHubspotRateLimitTest100% С8• Thu 14 May 17:30:37Q:= custom.logTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php XPlainTextDecorateActivity.phpA HS_local [jiminny@localho:Wv D IntegrationApp© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18> O Accessors› Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php4 console [PROD].••> D Config> DDTO© HubspotPaginationService.phpHandleHubspotRateLimit.phpA console (EU]50classService extendsBaseService implements> O Filters1531A5 A 119 X3 X9 ^DGo jiminny› Jobs.15320841A3 X4 A VC ProspectSearchStrategy1 usage211m migrations oi• ServiceTraits1533privatefunction buildTextMessagePayload(Activity Sactivity): Activity212© DataClient.php=213m teams where© DecorateActivity.php15341535Stype = $this->getCrmType(Sactivity->category);- 214m crm_layouts !© LocalSearch.php1536=215iM crm_layout_ei© LocalSearchinterface.php1537Annotate with Git Blameage:'No mapped CRM type in Pipedrive for "'• $activity->category->name→ 216IM crm_fields Wi© RemoteSearch.php1538Add Bookmark₴8F2© Service.php1539Add Mnemonic Bookmark...TF3-217218m features;v C Listeners: 2191540© ConvertLeadActivities.phpSoft-Wrap1 28;1541E220m team_feature:m opportunitie:© PurgeLookupCache.php1542Configure Soft Wraps...— 221.Le->crm_provider_id,> Metadata-222221543Appearance•created_at->format( format: 'H:i'),m teams;> C Migration•2231544Configure Gutter Icons...v @ Pipedrive•created_at->toDateString,— 22415451.id, CASEWHEN> D OpportunitySyncStrategy"IM™1546'subject' => Sthis->generateActivityTitle(Sactivity),— 225> D ProspectSearchStrategy1547=226'note' => Sthis->generateActivityDescription(Sactivity),© ApiFields.php1548] + $this->convertActivityAssociations($activity);= 227idFROMsocial.© Client.php22815491 on u.id© FieldDefinitions.php1550return Sthis->upsertActivity(Sactivity, $data);© PipedriveApiClient.phpf229= sa.:1..n<->1: on t.1551• 230Lid = 1052 and© PipedriveApiException.php= 2311552© Service.php232IMaccounts whei2 usages© TokenStorage.php1553private function upsertActivity(Activity $activity, array $data): Activity{...}= 233 vactivitiesv 0 Salesforce1576=234> M FioldoWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1535:34 (10 chars)UTF-8Co 4 spaces...
|
NULL
|
3782588862398590518
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit K vProject vReportController.php© AutomatedReportGenerated.phpablHandleHubspotRateLimitTest100% С8• Thu 14 May 17:30:37Q:= custom.logTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php XPlainTextDecorateActivity.phpA HS_local [jiminny@localho:Wv D IntegrationApp© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18> O Accessors› Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php4 console [PROD].••> D Config> DDTO© HubspotPaginationService.phpHandleHubspotRateLimit.phpA console (EU]50classService extendsBaseService implements> O Filters1531A5 A 119 X3 X9 ^DGo jiminny› Jobs.15320841A3 X4 A VC ProspectSearchStrategy1 usage211m migrations oi• ServiceTraits1533privatefunction buildTextMessagePayload(Activity Sactivity): Activity212© DataClient.php=213m teams where© DecorateActivity.php15341535Stype = $this->getCrmType(Sactivity->category);- 214m crm_layouts !© LocalSearch.php1536=215iM crm_layout_ei© LocalSearchinterface.php1537Annotate with Git Blameage:'No mapped CRM type in Pipedrive for "'• $activity->category->name→ 216IM crm_fields Wi© RemoteSearch.php1538Add Bookmark₴8F2© Service.php1539Add Mnemonic Bookmark...TF3-217218m features;v C Listeners: 2191540© ConvertLeadActivities.phpSoft-Wrap1 28;1541E220m team_feature:m opportunitie:© PurgeLookupCache.php1542Configure Soft Wraps...— 221.Le->crm_provider_id,> Metadata-222221543Appearance•created_at->format( format: 'H:i'),m teams;> C Migration•2231544Configure Gutter Icons...v @ Pipedrive•created_at->toDateString,— 22415451.id, CASEWHEN> D OpportunitySyncStrategy"IM™1546'subject' => Sthis->generateActivityTitle(Sactivity),— 225> D ProspectSearchStrategy1547=226'note' => Sthis->generateActivityDescription(Sactivity),© ApiFields.php1548] + $this->convertActivityAssociations($activity);= 227idFROMsocial.© Client.php22815491 on u.id© FieldDefinitions.php1550return Sthis->upsertActivity(Sactivity, $data);© PipedriveApiClient.phpf229= sa.:1..n<->1: on t.1551• 230Lid = 1052 and© PipedriveApiException.php= 2311552© Service.php232IMaccounts whei2 usages© TokenStorage.php1553private function upsertActivity(Activity $activity, array $data): Activity{...}= 233 vactivitiesv 0 Salesforce1576=234> M FioldoWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1535:34 (10 chars)UTF-8Co 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45313
|
1624
|
29
|
2026-05-14T14:30:36.042250+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769036042_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:33Yesterday at 13:32Yesterdav at 13:37Yesterday at 13:31Yesterday at 13.30Yesterday at 13:30Yesterday at 13:29resterday at 15.4gYesterdav at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:26resterday at 13-4oYesterdav at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13:22Yesterday at 13.21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:17Yesterday at 13:16Yesterday at 13-10Yesterdav at 13:15Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:12Yesterday at 13.12Yesterdav at 13:12Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:10Yesterday at 13:09Yesterday at 13.09Yocterdav at 12:081Yesterday at 13:08Yesterdav at 13:071S K:MPEG-4 movie16 KBMPEG-4 movie17 KB29 Kb6 KEMPEG-4 movieMPEG-4 movie12 KB9 KBMPE0"4 movie7 K:8 KBMPEG-4 movie37 KB10 KbMP2G-4 movie7 KBMPEG-4 movie8 KBMPEG-4 movie8 K:72 KBMPEG-4 movie14 KB13 K:O KRIMPEG-4 movieMPEG-4 movie18 KB12 KBMPEG-4 movie10 KEMPEG-4 movie16 KB MPEG-4 movie6 KB6 KB1MPEG-4 movie12 KBMPEG-4 movie23 KB8 KBMPEG-4 movie6KEMPEG-4 movie6 KBMPEG-4 movie11 KB11 K:MPEG-4 movie20 KBMPEG-4 movie10 KBMPEG-4 movie7 KPMPEG-4 movie5 KBMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 KBMPEG-4 movie102 KB88 KBMPEG-4 movieKO KRMDEG-A movie98 KBMPEG-4 movie97 KBMPEG-4 movie66 K:MPEG-A movie44 KB MPEG-4 movie93 KB78 KB50 kpMPEG-4 movieMPEG-4 movie58 KB27 KBMPEG-4 movie7KP12 KBMPEG-4 movie32 KB17 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network_• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.52 GB availabld• Inu 14 May 1/.30•32Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
528128413693538734
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:33Yesterday at 13:32Yesterdav at 13:37Yesterday at 13:31Yesterday at 13.30Yesterday at 13:30Yesterday at 13:29resterday at 15.4gYesterdav at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:26resterday at 13-4oYesterdav at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13:22Yesterday at 13.21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:17Yesterday at 13:16Yesterday at 13-10Yesterdav at 13:15Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:12Yesterday at 13.12Yesterdav at 13:12Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:10Yesterday at 13:09Yesterday at 13.09Yocterdav at 12:081Yesterday at 13:08Yesterdav at 13:071S K:MPEG-4 movie16 KBMPEG-4 movie17 KB29 Kb6 KEMPEG-4 movieMPEG-4 movie12 KB9 KBMPE0"4 movie7 K:8 KBMPEG-4 movie37 KB10 KbMP2G-4 movie7 KBMPEG-4 movie8 KBMPEG-4 movie8 K:72 KBMPEG-4 movie14 KB13 K:O KRIMPEG-4 movieMPEG-4 movie18 KB12 KBMPEG-4 movie10 KEMPEG-4 movie16 KB MPEG-4 movie6 KB6 KB1MPEG-4 movie12 KBMPEG-4 movie23 KB8 KBMPEG-4 movie6KEMPEG-4 movie6 KBMPEG-4 movie11 KB11 K:MPEG-4 movie20 KBMPEG-4 movie10 KBMPEG-4 movie7 KPMPEG-4 movie5 KBMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 KBMPEG-4 movie102 KB88 KBMPEG-4 movieKO KRMDEG-A movie98 KBMPEG-4 movie97 KBMPEG-4 movie66 K:MPEG-A movie44 KB MPEG-4 movie93 KB78 KB50 kpMPEG-4 movieMPEG-4 movie58 KB27 KBMPEG-4 movie7KP12 KBMPEG-4 movie32 KB17 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network_• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.52 GB availabld• Inu 14 May 1/.30•32Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45312
|
1623
|
40
|
2026-05-14T14:30:36.028594+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769036028_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest100% С8• Thu 14 May 17:30:35QProject v© ReportController.php© AutomatedReportGenerated.php:= custom.logTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpv D IntegrationApp© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18> O Accessors› Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php.••> D Config> DDTO© HubspotPaginationService.phpHandleHubspotRateLimit.php50classService extendsBaseService implements> O Filters153185 A 119 ×3 X9 ^D› Jobs.1532C ProspectSearchStrategy1 usage211• ServiceTraits1533privatefunction buildTextMessagePayload(Activity Sactivity): Activity212© DataClient.php=213© DecorateActivity.php15341535$type = $this->getCrmType(Sactivity->category);=214© LocalSearch.php1536if ($type === null) {=215© LocalSearchinterface.php1537throw new Exception( message:'No mapped CRM type in Pipedrive for "' . $activity->category->name→ 216© RemoteSearch.php1538-217© Service.php1539218v C Listeners1540$data = [219© ConvertLeadActivities.php1541'done' => 1,E220© PurgeLookupCache.php1542'user_id' => $this->profile->crm_provider_id,=221> Metadata1543'due_time' => $activity->crepted_at->format( format: 'H:i'),:222> C Migration1544'due_date' => $activity->created_at->toDateString(),223v @ Pipedrive1545'type' => $type->value,— 224> D OpportunitySyncStrategy1546'subject' = Sthis->generateActivityTitle(Sactivity),— 225> D ProspectSearchStrategy1547=226'note' => Sthis->generateActivityDescription(Sactivity),© ApiFields.php1548] + $this->convertActivityAssociations($activity);— 227© Client.php2281549© FieldDefinitions.phpT:2291550return Sthis-›upsertActivity(Sactivity, $data);© PipedriveApiClient.php- 2301551© PipedriveApiException.php= 2311552© Service.php2322 usages© TokenStorage.php1553private function upsertActivity(Activity $activity, array $data): Activity{...}= 233 vv 0 Salesforce1576=234> M FioldsWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASEWHENidFROMsocial.1 on u.id= sa.:1..n<->1: on t.Lid= 1052 andIMaccounts wheiactivitiesW Windsurf Teams1543:41UTF-8Co 4 spaces...
|
NULL
|
-710392623800239730
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpabl•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest100% С8• Thu 14 May 17:30:35QProject v© ReportController.php© AutomatedReportGenerated.php:= custom.logTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpv D IntegrationApp© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18> O Accessors› Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php.••> D Config> DDTO© HubspotPaginationService.phpHandleHubspotRateLimit.php50classService extendsBaseService implements> O Filters153185 A 119 ×3 X9 ^D› Jobs.1532C ProspectSearchStrategy1 usage211• ServiceTraits1533privatefunction buildTextMessagePayload(Activity Sactivity): Activity212© DataClient.php=213© DecorateActivity.php15341535$type = $this->getCrmType(Sactivity->category);=214© LocalSearch.php1536if ($type === null) {=215© LocalSearchinterface.php1537throw new Exception( message:'No mapped CRM type in Pipedrive for "' . $activity->category->name→ 216© RemoteSearch.php1538-217© Service.php1539218v C Listeners1540$data = [219© ConvertLeadActivities.php1541'done' => 1,E220© PurgeLookupCache.php1542'user_id' => $this->profile->crm_provider_id,=221> Metadata1543'due_time' => $activity->crepted_at->format( format: 'H:i'),:222> C Migration1544'due_date' => $activity->created_at->toDateString(),223v @ Pipedrive1545'type' => $type->value,— 224> D OpportunitySyncStrategy1546'subject' = Sthis->generateActivityTitle(Sactivity),— 225> D ProspectSearchStrategy1547=226'note' => Sthis->generateActivityDescription(Sactivity),© ApiFields.php1548] + $this->convertActivityAssociations($activity);— 227© Client.php2281549© FieldDefinitions.phpT:2291550return Sthis-›upsertActivity(Sactivity, $data);© PipedriveApiClient.php- 2301551© PipedriveApiException.php= 2311552© Service.php2322 usages© TokenStorage.php1553private function upsertActivity(Activity $activity, array $data): Activity{...}= 233 vv 0 Salesforce1576=234> M FioldsWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASEWHENidFROMsocial.1 on u.id= sa.:1..n<->1: on t.Lid= 1052 andIMaccounts wheiactivitiesW Windsurf Teams1543:41UTF-8Co 4 spaces...
|
45310
|
NULL
|
NULL
|
NULL
|
|
45311
|
1624
|
28
|
2026-05-14T14:30:35.226339+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769035226_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","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\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.28224733,"top":1.0,"width":0.024268618,"height":-0.04788506},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
45308
|
NULL
|
NULL
|
NULL
|
|
45310
|
1623
|
39
|
2026-05-14T14:30:35.191078+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769035191_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.73194444,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"bounds":{"left":0.75277776,"top":0.3122222,"width":0.023611112,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.78055555,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45309
|
1623
|
38
|
2026-05-14T14:30:31.824469+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769031824_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-4027196623176036612
|
-8132283174711358517
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
PhpStorm•Project vFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpFV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vablHandleHubspotRateLimitTest100% С8• Thu 14 May 17:30:31Q© AutomatedReportGenerated.php:=custom.logReportController.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]© WebhookSyncBatchProcessv D IntegrationApp18.••> O Accessors> Api|Salesforce/Service.php© ActivityPlaybookTrait.phpE.envT LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpA HS_local [jiminny@localho:W©.CrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] XDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php4 console [PROD]> D Config> DDTO© HubspotPaginationService.phpHandleHubspotRateLimit.phpclassService extendsBaseService implementsA console (EU]> O Filters› Jobs.501531153285 A 119 ×3 X9 ^C ProspectSearchStrategy• ServiceTraits1 usageprivatefunction buildTextMessagePayload(Activity Sactivity): Activity© DataClient.php© DecorateActivity.php© LocalSearch.php© LocalSearchinterface.php© RemoteSearch.php© Service.phpv C Listeners© ConvertLeadActivities.php© PurgeLookupCache.php> Metadata> C Migrationv @ Pipedrive> D OpportunitySyncStrategy> D ProspectSearchStrategy© ApiFields.php© Client.php© FieldDefinitions.php© PipedriveApiClient.php© PipedriveApiException.php Cascade 2 ClCommand 281ExtractSurround/esaata = l'done' => 1,'user_id' => $this->profile->crm_provider_id,'due_time' => $activity->created_at->format( format:'H:i'),'due_date' => $activity->created_at->toDateString(),'type' => $type->value,'subject' = Sthis->generateActivityTitle(Sactivity),'note' => Sthis->generateActivityDescription(Sactivity),] + $this->convertActivityAssociations($activity);© Service.php2 usages© TokenStorage.phpv 0 Salesforce15531576private function upsertActivity(Activity $activity, array $data): Activity{...}D211212=213$type = $this->getCrmType(Sactivity->category):=214if ($type === null) €=:215throw new Exception( message:'No mappedCRM type in Pipedrive for "' . $activity->category->name=216217218219E220— 221-22222•223— 224— 225=226— 227228229• 230= 231232= 233 v=234Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;team_feature:m opportunitie:teams;1.id,CASEWHENreturn Sthis-›upsertActivity(Sactivity, $data);idFROMsocial,i on u.id= sa.:1..n<->1: on t.Lid= 1052 andIMaccounts wheiactivities> M FioldsWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1538:10 (195 chars, 3 line breaks)UTF-8Co 4 spaces...
|
45307
|
NULL
|
NULL
|
NULL
|
|
45308
|
1624
|
27
|
2026-05-14T14:30:29.818641+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769029818_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:33Yesterday at 13:32Yesterdav at 13:37Yesterday at 13:31Yesterday at 13.30Yesterday at 13:30Yesterday at 13:29resterday at 15.4gYesterdav at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:26resterday at 13-4oYesterdav at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13:22Yesterday at 13.21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:17Yesterday at 13:16Yesterday at 13-10Yesterdav at 13:15Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:12Yesterday at 13.12Yesterdav at 13:12Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:10Yesterday at 13:09Yesterday at 13.09Yocterdav at 12:081Yesterday at 13:08Yesterdav at 13:071S K:MPEG-4 movie16 KBMPEG-4 movie17 KB29 Kb6 KEMPEG-4 movieMPEG-4 movie12 KB9 KBMPE0"4 movie7 K:8 KBMPEG-4 movie37 KB10 KbMP2G-4 movie7 KBMPEG-4 movie8 KBMPEG-4 movie8 K:72 KBMPEG-4 movie14 KB13 K:O KRIMPEG-4 movieMPEG-4 movie18 KB12 KBMPEG-4 movie10 KEMPEG-4 movie16 KB MPEG-4 movie6 KB6 KB1MPEG-4 movie12 KBMPEG-4 movie23 KB8 KBMPEG-4 movie6KEMPEG-4 movie6 KBMPEG-4 movie11 KB11 K:MPEG-4 movie20 KBMPEG-4 movie10 KBMPEG-4 movie7 KPMPEG-4 movie5 KBMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 KBMPEG-4 movie102 KB88 KBMPEG-4 movieKO KRMDEG-A movie98 KBMPEG-4 movie97 KBMPEG-4 movie66 K:MPEG-A movie44 KB MPEG-4 movie93 KB78 KB50 kpMPEG-4 movieMPEG-4 movie58 KB27 KBMPEG-4 movie7KP12 KBMPEG-4 movie32 KB17 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zipD report(2) xmlAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentPDF Document91 KB91 KB30 KB29 KBXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.52 GB availabld• Inu 14 May 1/.30.49Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-256774956874792479
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:33Yesterday at 13:32Yesterdav at 13:37Yesterday at 13:31Yesterday at 13.30Yesterday at 13:30Yesterday at 13:29resterday at 15.4gYesterdav at 13:28Yesterday at 13:28Yesterday at 13:27Yesterday at 13:26resterday at 13-4oYesterdav at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13:22Yesterday at 13.21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:17Yesterday at 13:16Yesterday at 13-10Yesterdav at 13:15Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:12Yesterday at 13.12Yesterdav at 13:12Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:10Yesterday at 13:09Yesterday at 13.09Yocterdav at 12:081Yesterday at 13:08Yesterdav at 13:071S K:MPEG-4 movie16 KBMPEG-4 movie17 KB29 Kb6 KEMPEG-4 movieMPEG-4 movie12 KB9 KBMPE0"4 movie7 K:8 KBMPEG-4 movie37 KB10 KbMP2G-4 movie7 KBMPEG-4 movie8 KBMPEG-4 movie8 K:72 KBMPEG-4 movie14 KB13 K:O KRIMPEG-4 movieMPEG-4 movie18 KB12 KBMPEG-4 movie10 KEMPEG-4 movie16 KB MPEG-4 movie6 KB6 KB1MPEG-4 movie12 KBMPEG-4 movie23 KB8 KBMPEG-4 movie6KEMPEG-4 movie6 KBMPEG-4 movie11 KB11 K:MPEG-4 movie20 KBMPEG-4 movie10 KBMPEG-4 movie7 KPMPEG-4 movie5 KBMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 KBMPEG-4 movie102 KB88 KBMPEG-4 movieKO KRMDEG-A movie98 KBMPEG-4 movie97 KBMPEG-4 movie66 K:MPEG-A movie44 KB MPEG-4 movie93 KB78 KB50 kpMPEG-4 movieMPEG-4 movie58 KB27 KBMPEG-4 movie7KP12 KBMPEG-4 movie32 KB17 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zipD report(2) xmlAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВZIP archivel148 KBZIP archive122 KBXML document111 KBAlfred...ferences94 KBPDF Document92 KBPDr DocumentPDF Document91 KB91 KB30 KB29 KBXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.52 GB availabld• Inu 14 May 1/.30.49Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45307
|
1623
|
37
|
2026-05-14T14:30:29.837035+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769029837_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStorm•Project vFileEditViewNavigateCodeLaravelR PhpStorm•Project vFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpFV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vabl100% СHandleHubspotRateLimitTest8• Thu 14 May 17:30:29* :Q© AutomatedReportGenerated.php:= custom.logReportController.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpv D IntegrationApp© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18> O Accessors› Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php.••> D Config> DDTO© HubspotPaginationService.phpHandleHubspotRateLimit.php50classService extendsBaseService implements> O Filters153185 A 119 ×3 X9 ^D› Jobs.1532C ProspectSearchStrategy1 usage211• ServiceTraits1533privatefunctionpuildTextMessagePayload(Activity $activity): Activity212© DataClient.php=213© DecorateActivity.php15341535$type = $this->getCrmType(Sactivity->category):=214© LocalSearch.php1536if ($type === null) {=215© LocalSearchinterface.php1537throw new Exception( message:'No mapped CRM type in Pipedrive for "' . $activity->category->name→ 216© RemoteSearch.php1538-217© Service.php1539218v C Listeners1540$data = [: 219© ConvertLeadActivities.php1541'done' => 1,E220© PurgeLookupCache.php1542'user_id' => $this->profile->crm_provider_id,— 221> Metadata-222221543'due_time' => $activity->created_at->format( format: 'H:i'),> C Migration1544'due_date' => $activity->created_at->toDateString(),•223v @ Pipedrive— 2241545'type' => $type->value,> D OpportunitySyncStrategy1546'subject' = Sthis->generateActivityTitle(Sactivity),— 225> D ProspectSearchStrategy1547=226'note' => Sthis->generateActivityDescription(Sactivity),© ApiFields.php1548] + $this->convertActivityAssociations($activity);— 227© Client.php2281549© FieldDefinitions.php1550return Sthis-›upsertActivity(Sactivity, $data);© PipedriveApiClient.php72291551• 230© PipedriveApiException.php= 2311552© Service.php2322 usages© TokenStorage.php1553private function upsertActivity(Activity $activity, array $data): Activity{...}= 233 vv 0 Salesforce1576=234> M FioldsWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASEWHENidFROMsocial.1 on u.id= sa.:1..n<->1: on t.Lid = 1052 andIMaccounts wheiactivities1533:22UTF-8Co 4 spaces...
|
NULL
|
3631046043849039008
|
NULL
|
click
|
ocr
|
NULL
|
PhpStorm•Project vFileEditViewNavigateCodeLaravelR PhpStorm•Project vFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelpFV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vabl100% СHandleHubspotRateLimitTest8• Thu 14 May 17:30:29* :Q© AutomatedReportGenerated.php:= custom.logReportController.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpv D IntegrationApp© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18> O Accessors› Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php.••> D Config> DDTO© HubspotPaginationService.phpHandleHubspotRateLimit.php50classService extendsBaseService implements> O Filters153185 A 119 ×3 X9 ^D› Jobs.1532C ProspectSearchStrategy1 usage211• ServiceTraits1533privatefunctionpuildTextMessagePayload(Activity $activity): Activity212© DataClient.php=213© DecorateActivity.php15341535$type = $this->getCrmType(Sactivity->category):=214© LocalSearch.php1536if ($type === null) {=215© LocalSearchinterface.php1537throw new Exception( message:'No mapped CRM type in Pipedrive for "' . $activity->category->name→ 216© RemoteSearch.php1538-217© Service.php1539218v C Listeners1540$data = [: 219© ConvertLeadActivities.php1541'done' => 1,E220© PurgeLookupCache.php1542'user_id' => $this->profile->crm_provider_id,— 221> Metadata-222221543'due_time' => $activity->created_at->format( format: 'H:i'),> C Migration1544'due_date' => $activity->created_at->toDateString(),•223v @ Pipedrive— 2241545'type' => $type->value,> D OpportunitySyncStrategy1546'subject' = Sthis->generateActivityTitle(Sactivity),— 225> D ProspectSearchStrategy1547=226'note' => Sthis->generateActivityDescription(Sactivity),© ApiFields.php1548] + $this->convertActivityAssociations($activity);— 227© Client.php2281549© FieldDefinitions.php1550return Sthis-›upsertActivity(Sactivity, $data);© PipedriveApiClient.php72291551• 230© PipedriveApiException.php= 2311552© Service.php2322 usages© TokenStorage.php1553private function upsertActivity(Activity $activity, array $data): Activity{...}= 233 vv 0 Salesforce1576=234> M FioldsWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASEWHENidFROMsocial.1 on u.id= sa.:1..n<->1: on t.Lid = 1052 andIMaccounts wheiactivities1533:22UTF-8Co 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45306
|
1624
|
26
|
2026-05-14T14:30:22.767360+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769022767_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2586234742233688171
|
-7051436856496256053
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeavev Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:33Yesterday at 13:32Yesterdav at 13:37Yesterday at 13:31Yesterday at 13.30Yesterdav at 13:30Yesterday at 13:29resterday at 15.4gYesterdav at 13:28Yesterday at 13:28Yesterday at 13:27Yesterdav at 13:26resterday at 13-4oYesterdav at 13:25Yesterday at 13:24Yesterday at 13:24Yesterday at 13:23Voctorday at 12:22Yesterday at 13.21Yesterdav at 13:20Yesterdav at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:17Yesterdav at 13:16Yesterday at 13-10Yesterdav at 13:15Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:12Yesterday at 13.12Yesterdav at 13:12Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:10Yesterdav at 13:09Yesterday at 13.09Yocterdav at 12:081Yesterdav at 13:08Yesterdav at 13:071S K:MPEG-4 movie16 KB17 KB29 Kb6 KE12 KB9 KB7 K:MDEG-A movieMPEG-4 movieMPEG-4 movieMPE0"4 movie8 KBMDSG-A movie37 KB10 KbMP2G-4 movie7 KBI8 KBMPEG-4 movieMPEG-4 movie8 K:72 KB14 KE13 K:MoECh movioMPEG-4 movieO KRI18 KB12 KBMPEG-1 movidMPEG-4 movie10 KEMPEG-4 movie16 KB MPEG-4 movie6 KB6 KB1MPEG-4 movie12 KB23 KB8 KBMDSG.A movicMPEG-4 movie6KE6 KBMPEG-4 movieMPEG-4 movie11 KB11 K:MPEG-4 movie20 kRMDEC A movid10 KBMPEG-4 movie7 KPMDEG-A movie5 KBMPEG-4 movie11 KBMPEG-4 movie26 KB111 KB102 KB88 KBMPEG-4 movieMDEC.A movialMPEG-4 movieKO KRMDEG-A movie98 KBMPEG-4 movie97 KBMPEG-4 movie66 K:MPEG-A movie44 KB MPEG-4 movie93 KB78 KB50 kpMPEG-4 movieMDEG.A movio58 KB27 KBMPEG-4 movie7KP12 KBMADECA movid32 KB17 K8MPEG-4 movieFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence hettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.53 GB availabld• Inu 14 May 1/.30.L4Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45304
|
NULL
|
NULL
|
NULL
|
|
45305
|
1623
|
36
|
2026-05-14T14:30:22.795383+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769022795_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2586234742233688171
|
-7051436856496256053
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vProject v© ReportController.php© AutomatedReportGenerated.phpablHandleHubspotRateLimitTest100% C8• Thu 14 May 17:30:22* :Q:=custom.logTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.php© SyncFieldAction.php© SyncRelatedActivityManage© WebhookSyncBatchProcessv D IntegrationApp18.••> O Accessors> Api|> D Config> DDTO> O Filters› Jobs.> D ProspectSearchStrategy• ServiceTraits© DataClient.php© DecorateActivity.php© LocalSearch.php© LocalSearchinterface.php© RemoteSearch.php© Service.phpv C Listeners© ConvertLeadActivities.php© PurgeLookupCache.php> Metadata> C Migrationv D Pipedrive> D OpportunitySyncStrategy› D ProspectSearchStrategy© ApiFields.php© Client.php© FieldDefinitions.php© PipedriveApiClient.php© PipedriveApiException.php© Service.php© TokenStorage.phpv 0 Salesforce> M FioldisUnhandled \ExceptionUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpA HS_local [jiminny@localho:W© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] XE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php4 console [PROD]© HubspotPaginationService.phpHandleHubspotRateLimit.phpclass Service extendsBaseService implements*/A console (EU]50159811399 C >14791480148114821483 C A5 A 119 X3 X9 ^public function matchByName(string $name, ?int $userId = null): ?array{...}/***/* @inheritdocpublic function saveActivity(Activity $activity): Activityswitch (Sactivity->type) {case Activity:: TYPE_CONFERENCE:case Activity: : TYPE_SOFTPHONE:case Activity::TYPE_S0FTPHONE_INBOUND:Sactivity = $this->buildCallPayload($activity);break;case Activity::TYPE_SMS_INBOUND:case Activity::TYPE_SMS_OUTBOUND:Sactivity = $this->buildTextMessagePayload(Sactivity);break;return $activity:4 usagesW Windsurf TeamsD211Go jiminny0841A3 X4 A Vm migrations oi212=213|=214=215216=m teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wi217=218219220m features;m team_feature:m opportunitie:III221III11.222m teams;- 223= 224|1.id, CASEWHEN-225226- 227= 228229=230idFROMsocial,on u.id= sa.:1..n<->1: on t.L_id = 1052 and=231— 232=233 vIMaccounts wheiactivities=2341489:46UTF-8C. 4 spaces...
|
45302
|
NULL
|
NULL
|
NULL
|
|
45304
|
1624
|
25
|
2026-05-14T14:30:20.957794+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769020957_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","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\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.28224733,"top":1.0,"width":0.024268618,"height":-0.04788506},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45303
|
1624
|
24
|
2026-05-14T14:30:16.942810+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769016942_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeavev Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:33Yesterday at 13:32Yesterday at 13:32Yesterday at 13:31Yesterday at 13.30Yesterdav at 13:29Yesterday at 13:29resterday at 15.2oYesterdav at 13:28Yesterday at 13:27Yesterday at 13:26Yesterdav at 13:26resterday at 13-40Yesterdav at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.20Yesterdav at 13:20Yesterdav at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:16Yesterdav at 13:16Yesterday at 13.10Yesterdav at 13:15Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterdav at 13:12Yesterday at 13.12Yesterdav at 13:11Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:00Yesterdav at 13:09Yesterday at 13.08Yocterdav at 12:08Yesterdav at 13:07Yesterdav at 13:0616 K:MPEG-4 movie17 KB29 KBMDEG-A movieMPEG-4 movieMPEG-4 movie12 KE9 KBMPE0"4 movie8 K:37 KB10 KBIKBnMDSG-A movieMP2G-4 movie8 KBI9 KB8 KB72 K:MPEG-4 movieMPEG-4 movie14 KB13 KBMoECA movieMPEG-4 movie18 KBI12 KB10 KBMPEG-1 movidMPEG-4 movie16 KEMPEG-4 movie6 KB MPEG-4 movie6 KB12 KB|MPEG-4 movie23 KBMDSG.A movicMPEG-4 movie6KE11 KBMPEG-4 movieMPEG-4 movie11 KBMPEG-4 movie20 K:MPEG-4 movie2AKR10 KB7 KBMDEGA movidMPEG-4 movie5KPMDEG-A movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 K:102 KB88 KB59 KBMPEG-4 movieMDEC.A moviaMPEG-4 movieO9 KR97 KB66 KBMDEG-A movieMPEG-4 movie44K:MPSG-A movid93 KBMPEG-4 movie78 KB50 KB59 KPMPEG-4 movieMDEG.A movio27 KBMPEG-4 movie12 K:32 KBMDEC A movid17 KB19K8MPEG-4 movie• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfP Kdf-a,k Ffmiv Tree.sebitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.53 GB availabld• Inu 14 May 1/.30-10Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-6604895547334571763
|
NULL
|
click
|
ocr
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeavev Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:33Yesterday at 13:32Yesterday at 13:32Yesterday at 13:31Yesterday at 13.30Yesterdav at 13:29Yesterday at 13:29resterday at 15.2oYesterdav at 13:28Yesterday at 13:27Yesterday at 13:26Yesterdav at 13:26resterday at 13-40Yesterdav at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.20Yesterdav at 13:20Yesterdav at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:16Yesterdav at 13:16Yesterday at 13.10Yesterdav at 13:15Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterdav at 13:12Yesterday at 13.12Yesterdav at 13:11Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:00Yesterdav at 13:09Yesterday at 13.08Yocterdav at 12:08Yesterdav at 13:07Yesterdav at 13:0616 K:MPEG-4 movie17 KB29 KBMDEG-A movieMPEG-4 movieMPEG-4 movie12 KE9 KBMPE0"4 movie8 K:37 KB10 KBIKBnMDSG-A movieMP2G-4 movie8 KBI9 KB8 KB72 K:MPEG-4 movieMPEG-4 movie14 KB13 KBMoECA movieMPEG-4 movie18 KBI12 KB10 KBMPEG-1 movidMPEG-4 movie16 KEMPEG-4 movie6 KB MPEG-4 movie6 KB12 KB|MPEG-4 movie23 KBMDSG.A movicMPEG-4 movie6KE11 KBMPEG-4 movieMPEG-4 movie11 KBMPEG-4 movie20 K:MPEG-4 movie2AKR10 KB7 KBMDEGA movidMPEG-4 movie5KPMDEG-A movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 K:102 KB88 KB59 KBMPEG-4 movieMDEC.A moviaMPEG-4 movieO9 KR97 KB66 KBMDEG-A movieMPEG-4 movie44K:MPSG-A movid93 KBMPEG-4 movie78 KB50 KB59 KPMPEG-4 movieMDEG.A movio27 KBMPEG-4 movie12 K:32 KBMDEC A movid17 KB19K8MPEG-4 movie• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfP Kdf-a,k Ffmiv Tree.sebitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.53 GB availabld• Inu 14 May 1/.30-10Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45300
|
NULL
|
NULL
|
NULL
|
|
45302
|
1623
|
35
|
2026-05-14T14:30:19.018579+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769019018_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.73194444,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"bounds":{"left":0.75277776,"top":0.3122222,"width":0.023611112,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.78055555,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45301
|
1623
|
34
|
2026-05-14T14:30:15.031503+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769015031_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vProject v© ReportController.php© AutomatedReportGenerated.phpabl100% CHandleHubspotRateLimitTest* :8• Thu 14 May 17:30:14QTrackAutomatedReportGeneratedEvent.phpPlaybackController.php:=custom.log© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php XPlainTextDecorateActivity.phpA HS_local [jiminny@localho:Wv D IntegrationApp© ActivityPlaybookTrait.php© CrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18.••> O Accessors› Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.phpClient.php4 console [PROD]> D Config> DDTO> O Filters© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass Service extends BaseService implementspublic function saveActivity(Activity $activity): ActivityA console (EU]› Jobs.> D ProspectSearchStrategy• ServiceTraits© DataClient.php5014831498149915001501A5 A119 X3 X9 ^return Sactivity:© DecorateActivity.php4 usages© LocalSearch.php© LocalSearchinterface.php15021510private function convertActivityAssociations(Activity $activity): array{...}© RemoteSearch.php1 usage© Service.php1511private function buildCallPayload(Activity $activity): Activityv C Listeners1512© ConvertLeadActivities.php1513© PurgeLookupCache.php1514> Metadata1515> C Migration1516v @ Pipedrive1517> D OpportunitySyncStrategy1518> D ProspectSearchStrategy1519© ApiFields.php1520© Client.php1521© FieldDefinitions.php1522© PipedriveApiClient.php1523© PipedriveApiException.php1524© Service.php1525© TokenStorage.php1526v 0 Salesforce1527D211212=213=214=215)216=217= 218IIII219$type = $this->getCrmType($activity->category);220if ($type === null) {221=throw new Exception( message:'No mapped CRM type in Pipedrive for "'• $activity->category->name=. 222— 223|= 224|-225- 226=227- 228U229=230231— 232=233 v=234Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASEWHENSdata = ['done' => 1,'user_id' = Sthis->profile-›crm_provider_id,'due_time' => Sactivity->scheduled_start_time->format( format: 'H:i'),'due_date' => Sactivity-›scheduled_start_time->toDateStringo,idFROMsocial,1 on u.id= sa.:1..n<->1: on t.Lid = 1052 and'duration' => gmdate( format:'H:i', Sactivity->duration),'typel' => $type->value,'subject' => $this->generateActivityTitle($activity),'note' => $this->generateActivityDescription(Sactivity),IMaccounts wheiactivities] + $this->fetchCustomFieldData(Sactivity,objecttype: Field::0BJECT_TASK)1.0104418401/404100441048646Workspace associated with branch '.JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure...(today 16:17)W Windsurf Teams1524:18 (4 chars)UTF-8Co 4 spaces...
|
NULL
|
3986813598385399712
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vProject v© ReportController.php© AutomatedReportGenerated.phpabl100% CHandleHubspotRateLimitTest* :8• Thu 14 May 17:30:14QTrackAutomatedReportGeneratedEvent.phpPlaybackController.php:=custom.log© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.php XPlainTextDecorateActivity.phpA HS_local [jiminny@localho:Wv D IntegrationApp© ActivityPlaybookTrait.php© CrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18.••> O Accessors› Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.php© MatchActivityCrmData.phpClient.php4 console [PROD]> D Config> DDTO> O Filters© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass Service extends BaseService implementspublic function saveActivity(Activity $activity): ActivityA console (EU]› Jobs.> D ProspectSearchStrategy• ServiceTraits© DataClient.php5014831498149915001501A5 A119 X3 X9 ^return Sactivity:© DecorateActivity.php4 usages© LocalSearch.php© LocalSearchinterface.php15021510private function convertActivityAssociations(Activity $activity): array{...}© RemoteSearch.php1 usage© Service.php1511private function buildCallPayload(Activity $activity): Activityv C Listeners1512© ConvertLeadActivities.php1513© PurgeLookupCache.php1514> Metadata1515> C Migration1516v @ Pipedrive1517> D OpportunitySyncStrategy1518> D ProspectSearchStrategy1519© ApiFields.php1520© Client.php1521© FieldDefinitions.php1522© PipedriveApiClient.php1523© PipedriveApiException.php1524© Service.php1525© TokenStorage.php1526v 0 Salesforce1527D211212=213=214=215)216=217= 218IIII219$type = $this->getCrmType($activity->category);220if ($type === null) {221=throw new Exception( message:'No mapped CRM type in Pipedrive for "'• $activity->category->name=. 222— 223|= 224|-225- 226=227- 228U229=230231— 232=233 v=234Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASEWHENSdata = ['done' => 1,'user_id' = Sthis->profile-›crm_provider_id,'due_time' => Sactivity->scheduled_start_time->format( format: 'H:i'),'due_date' => Sactivity-›scheduled_start_time->toDateStringo,idFROMsocial,1 on u.id= sa.:1..n<->1: on t.Lid = 1052 and'duration' => gmdate( format:'H:i', Sactivity->duration),'typel' => $type->value,'subject' => $this->generateActivityTitle($activity),'note' => $this->generateActivityDescription(Sactivity),IMaccounts wheiactivities] + $this->fetchCustomFieldData(Sactivity,objecttype: Field::0BJECT_TASK)1.0104418401/404100441048646Workspace associated with branch '.JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure...(today 16:17)W Windsurf Teams1524:18 (4 chars)UTF-8Co 4 spaces...
|
45299
|
NULL
|
NULL
|
NULL
|
|
45300
|
1624
|
23
|
2026-05-14T14:30:13.349559+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769013349_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:33Yesterday at 13:32Yesterday at 13:32Yesterday at 13:31Yesterday at 13.30Yesterday at 13:29Yesterday at 13:29resterday at 15.2oYesterdav at 13:28Yesterday at 13:27Yesterday at 13:26Yesterday at 13:26resterday at 13-40Yesterdav at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.20Yesterdav at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:16Yesterday at 13:16Yesterday at 13.10Yesterdav at 13:15Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13.12Yesterdav at 13:11Yesterday at 13:11Yesterday at 13:10Yesterday at 13:09Yesterday at 13:09Yesterday at 13.08Yocterdav at 12:08Yesterday at 13:07Yesterdav at 13:0616 K:MPEG-4 movie17 KBMPEG-4 movie29 KBMPEG-4 movieMPEG-4 movie12 KE9 KBMPE0"4 movie8 K:37 KBMPEG-4 movie10 KBIKBnMP2G-4 movie8 KBMPEG-4 movie9 KB8 KBMPEG-4 movie72 K:14 KBMPEG-4 movie13 KBMPEG-4 movie18 KBMPEG-4 movie12 KB10 KBMPEG-4 movie16 KEMPEG-4 movie6 KB MPEG-4 movie6 KB12 KB|MPEG-4 movie23 KBMPEG-4 movieMPEG-4 movie6KEMPEG-4 movie11 KBMPEG-4 movie11 KBMPEG-4 movie20 K:MPEG-4 movie34 KBMPEG-4 movie10 KB7 KBMPEG-4 movie5KPMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 K:MPEG-4 movie102 KBMPEG-4 movie88 KB59 KBO9 KRMPEG-4 movieMDEG-A movie97 KB66 KBMPEG-4 movie44K:MPSG-A movid93 KBMPEG-4 movie78 KB50 KB59 KPMPEG-4 movieMPEG-4 movie27 KBMPEG-4 movie12 K:32 KBMPEG-4 movie17 KB19K8MPEG-4 movie• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfB Rovaix Famly Treo gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.54 GB availabld• Inu 14 Mаy 1/.30-12Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
244819949767205994
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:33Yesterday at 13:32Yesterday at 13:32Yesterday at 13:31Yesterday at 13.30Yesterday at 13:29Yesterday at 13:29resterday at 15.2oYesterdav at 13:28Yesterday at 13:27Yesterday at 13:26Yesterday at 13:26resterday at 13-40Yesterdav at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.20Yesterdav at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:16Yesterday at 13:16Yesterday at 13.10Yesterdav at 13:15Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13.12Yesterdav at 13:11Yesterday at 13:11Yesterday at 13:10Yesterday at 13:09Yesterday at 13:09Yesterday at 13.08Yocterdav at 12:08Yesterday at 13:07Yesterdav at 13:0616 K:MPEG-4 movie17 KBMPEG-4 movie29 KBMPEG-4 movieMPEG-4 movie12 KE9 KBMPE0"4 movie8 K:37 KBMPEG-4 movie10 KBIKBnMP2G-4 movie8 KBMPEG-4 movie9 KB8 KBMPEG-4 movie72 K:14 KBMPEG-4 movie13 KBMPEG-4 movie18 KBMPEG-4 movie12 KB10 KBMPEG-4 movie16 KEMPEG-4 movie6 KB MPEG-4 movie6 KB12 KB|MPEG-4 movie23 KBMPEG-4 movieMPEG-4 movie6KEMPEG-4 movie11 KBMPEG-4 movie11 KBMPEG-4 movie20 K:MPEG-4 movie34 KBMPEG-4 movie10 KB7 KBMPEG-4 movie5KPMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 K:MPEG-4 movie102 KBMPEG-4 movie88 KB59 KBO9 KRMPEG-4 movieMDEG-A movie97 KB66 KBMPEG-4 movie44K:MPSG-A movid93 KBMPEG-4 movie78 KB50 KB59 KPMPEG-4 movieMPEG-4 movie27 KBMPEG-4 movie12 K:32 KBMPEG-4 movie17 KB19K8MPEG-4 movie• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfB Rovaix Famly Treo gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.54 GB availabld• Inu 14 Mаy 1/.30-12Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45298
|
1624
|
22
|
2026-05-14T14:30:10.055318+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769010055_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","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}]...
|
-7764066423040434580
|
-6980502962097239094
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:33Yesterday at 13:32Yesterday at 13:32Yesterday at 13:31Yesterday at 13.30Yesterday at 13:29Yesterday at 13:29resterday at 15.2oYesterdav at 13:28Yesterday at 13:27Yesterday at 13:26Yesterday at 13:26resterday at 13-40Yesterdav at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.20Yesterdav at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:16Yesterday at 13:16Yesterday at 13.10Yesterdav at 13:15Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13.12Yesterdav at 13:11Yesterday at 13:11Yesterday at 13:10Yesterday at 13:09Yesterday at 13:09Yesterday at 13.08Yocterdav at 12:08Yesterday at 13:07Yesterdav at 13:0616 K:MPEG-4 movie17 KBMPEG-4 movie29 KBMPEG-4 movieMPEG-4 movie12 KE9 KBMPE0"4 movie8 K:37 KBMPEG-4 movie10 KBIKBnMP2G-4 movie8 KBMPEG-4 movie9 KB8 KBMPEG-4 movie72 K:14 KBMPEG-4 movie13 KBMPEG-4 movie18 KBMPEG-4 movie12 KB10 KBMPEG-4 movie16 KEMPEG-4 movie6 KB MPEG-4 movie6 KB12 KB|MPEG-4 movie23 KBMPEG-4 movieMPEG-4 movie6KEMPEG-4 movie11 KBMPEG-4 movie11 KBMPEG-4 movie20 K:MPEG-4 movie34 KBMPEG-4 movie10 KB7 KBMPEG-4 movie5KPMPEG-4 movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 K:MPEG-4 movie102 KBMPEG-4 movie88 KB59 KBO9 KRMPEG-4 movieMDEG-A movie97 KB66 KBMPEG-4 movie44K:MPSG-A movid93 KBMPEG-4 movie78 KB50 KB59 KPMPEG-4 movieMPEG-4 movie27 KBMPEG-4 movie12 K:32 KBMPEG-4 movie17 KB19K8MPEG-4 movie• 0FavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfB Rovaix Famly Treo gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pa• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.54 GB availabld• Inu 14 May 1/.30:04Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45296
|
NULL
|
NULL
|
NULL
|
|
45297
|
1623
|
32
|
2026-05-14T14:30:10.076528+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769010076_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7759925082980464890
|
-7195274138718106135
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vProject v© ReportController.php© AutomatedReportGenerated.phpablHandleHubspotRateLimitTest100% C8• Thu 14 May 17:30:09* :Q:=custom.logTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.php© SyncFieldAction.php© SyncRelatedActivityManage© WebhookSyncBatchProcessv D IntegrationApp18.••> O Accessors› Api|> D Config> DDTO> O Filters› Jobs.> D ProspectSearchStrategy• ServiceTraits© DataClient.php© DecorateActivity.php© LocalSearch.php© LocalSearchinterface.php© RemoteSearch.php© Service.phpv C Listeners© ConvertLeadActivities.php© PurgeLookupCache.php> Metadata> C Migrationv D Pipedrive> D OpportunitySyncStrategy› D ProspectSearchStrategy© ApiFields.php© Client.php© FieldDefinitions.php© PipedriveApiClient.php© PipedriveApiException.php© Service.php© TokenStorage.phpv 0 Salesforce> M FioldisUnhandled \ExceptionUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpA HS_local [jiminny@localho:W© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] XE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php4 console [PROD]© HubspotPaginationService.phpHandleHubspotRateLimit.phpclass Service extendsBaseService implements*/A console (EU]50159811399 € >14791480148114821483 C A5 A 119 X3 X9 ^public function matchByName(string $name, ?int $userId = null): ?array{...}/***/* @inheritdocpublic function saveActivity(Activity $activity): Activityswitch (Sactivity->type) {case Activity:: TYPE_CONFERENCE:case Activity: : TYPE_SOFTPHONE:case Activity::TYPE_SOFTPHONE_INBOUND:Sactivity = Sthis->buildCallPayLoad(Sactivity);break;case Activity::TYPE_SMS_INBOUND:case Activity: : TYPE_SMS_OUTBOUND:Sactivity = $this->buildTextMesskgePayload($activity);break;return $activity:4 usagesMeddranW Windsurf TeamsD211Go jiminny0841A3 X4 A Vm migrations oi212=213|=214=215216=m teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wi217= 218219220m features;m team_feature:m opportunitie:221=111.222m teams;- 223= 224|1.id, CASEWHEN-225Т2267227— 228229230,id FROM social,on u.id= sa.:1..n<->1: on t.L_id = 1052 and=231— 232=233 vIMaccounts wheiactivities=2341494:49UTF-8C 4 spaces...
|
45294
|
NULL
|
NULL
|
NULL
|
|
45296
|
1624
|
21
|
2026-05-14T14:30:08.707578+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778769008707_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","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\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.28224733,"top":1.0,"width":0.024268618,"height":-0.04788506},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45295
|
1624
|
20
|
2026-05-14T14:29:53.464134+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768993464_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday ar 13.31Yesterdav at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterdav at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Voctorday at 12:22Yesterday at 13.21Yesterdav at 13:21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterdav at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yoctorday at 12:10Yesterdav at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterdav at 13:08Yesterdav at 13:07MPEG-4 movie13 KB16 KB1/ Kb29 KE6 KB12 KB9 K:MPEG-4 movieMPEG-4 movieMPEG-4 movieMPE0"4 movie7 KBMDSG-A movid8 KE37 KBMP2G-4 movie10 KB7 KBMPEG-4 movieMPEG"4 movie9K:8 KBMoECA movio72 KB14 K8|MPEG-4 movie13 KRI9 KB18 KBMPEG-1 movidMPEG-4 movie12 KEMPEG-4 movie10 KB16 KBMPEG-4 movie6 KB1MPEG-4 movieMDSG.A movic12 KB23 KBMPEG-4 movie8 KE6 KBMPEG-4 movieMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KPMDEC A movid34 KBMPEG-4 movie10 KPMDEG-A movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBI26 KB111 KB102 KBMPEG-4 movieMDEC.A movialMPEG-4 movieRR KR59 KB98 KBMDEG-A movieMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMDEG.A movio58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.53 GB availabld• Inu 14 May 1/.29:02Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-1790064034803281664
|
NULL
|
click
|
ocr
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday ar 13.31Yesterdav at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterdav at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Voctorday at 12:22Yesterday at 13.21Yesterdav at 13:21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterdav at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yoctorday at 12:10Yesterdav at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterdav at 13:08Yesterdav at 13:07MPEG-4 movie13 KB16 KB1/ Kb29 KE6 KB12 KB9 K:MPEG-4 movieMPEG-4 movieMPEG-4 movieMPE0"4 movie7 KBMDSG-A movid8 KE37 KBMP2G-4 movie10 KB7 KBMPEG-4 movieMPEG"4 movie9K:8 KBMoECA movio72 KB14 K8|MPEG-4 movie13 KRI9 KB18 KBMPEG-1 movidMPEG-4 movie12 KEMPEG-4 movie10 KB16 KBMPEG-4 movie6 KB1MPEG-4 movieMDSG.A movic12 KB23 KBMPEG-4 movie8 KE6 KBMPEG-4 movieMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KPMDEC A movid34 KBMPEG-4 movie10 KPMDEG-A movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBI26 KB111 KB102 KBMPEG-4 movieMDEC.A movialMPEG-4 movieRR KR59 KB98 KBMDEG-A movieMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMDEG.A movio58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.53 GB availabld• Inu 14 May 1/.29:02Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45292
|
NULL
|
NULL
|
NULL
|
|
45294
|
1623
|
31
|
2026-05-14T14:29:55.904441+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768995904_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.73194444,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"bounds":{"left":0.75277776,"top":0.3122222,"width":0.023611112,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.78055555,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45293
|
1623
|
30
|
2026-05-14T14:29:53.450874+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768993450_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vablHandleHubspotRateLimitTestv100% С8• Thu 14 May 17:29:52QProject v© AutomatedReportGenerated.php:= custom.logReportController.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpA HS_local [jiminny@localho:Wv D IntegrationApp© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18.••> O Accessors> Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php4 console [PROD]> D Config> DDTO50> O Filters© HubspotPaginationService.phpHandleHubspotRateLimit.phpclass Service extendsBaseService implements1 usageA console (EU]A5 A 119 X3 X9 ^› Jobs.D ProspectSearchStrategy• ServiceTraits© DataClient.php© DecorateActivity.php© LocalSearch.php© LocalSearchinterface.php© RemoteSearch.php© Service.phpv C Listeners© ConvertLeadActivities.php© PurgeLookupCache.php> Metadata> C Migrationv @ Pipedrive> D OpportunitySyncStrategy> D ProspectSearchStrategy© ApiFields.php© Client.php© FieldDefinitions.php© PipedriveApiClient.php© PipedriveApiException.php private function buildCallPayload(Activity $activity): Activity$data = ['done' => 1,'user_id' => $this->profile->crm_provider_id,'due_time' => $activity->scheduled_start_time->format( format: 'H:i'),'due_date' => $activity->scheduled_start_time->toDateString(),'duration' => gmdate( format:'H:i', $activity->duration),'type' => $type->value,'subject' => $this->generateActivityTitle(Sactivity),'note' => $this->generateActivityDescription(Sactivity),] + $this->fetchCustomFieldData(Sactivity,objectType: Field::0BJECT_TASK)+ $this->convertActivityAssociations($activity);return $this->upsertActivity(Sactivity, $data);© Service.php© TokenStorage.php1 usage1533private function buildTextMessagePayload(Activity $activity): Activity{...}v 0 Salesforce> M Fioldo1552Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)D211212$type = $this->getCrmType($activity->category);= 213if ($type ===null){throw new Exception( message:'No mapped CRM type in Pipedrive for "' . $activity->category->name5214=216=217= 218219220III221.222— 223|= 224|-225-226=227: 228_229230=231— 232=233 v=2341513:29Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASEWHENidFROMsocial,1 on u.id= sa.:1..n<->1: on t.Lid= 1052 andIMaccounts wheiactivitiesW Windsurf TeamsUTF-8Co 4 spaces...
|
NULL
|
4683190347858477161
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEditViewNavigateCodeLaravelRefactorRun PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.jsv#12066 on JY-20725-handle-HS-search-rate-limit k vablHandleHubspotRateLimitTestv100% С8• Thu 14 May 17:29:52QProject v© AutomatedReportGenerated.php:= custom.logReportController.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php© Service.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© SyncFieldAction.php© SyncRelatedActivityManage• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]© WebhookSyncBatchProcessSalesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpA HS_local [jiminny@localho:Wv D IntegrationApp© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18.••> O Accessors> Api|E.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php4 console [PROD]> D Config> DDTO50> O Filters© HubspotPaginationService.phpHandleHubspotRateLimit.phpclass Service extendsBaseService implements1 usageA console (EU]A5 A 119 X3 X9 ^› Jobs.D ProspectSearchStrategy• ServiceTraits© DataClient.php© DecorateActivity.php© LocalSearch.php© LocalSearchinterface.php© RemoteSearch.php© Service.phpv C Listeners© ConvertLeadActivities.php© PurgeLookupCache.php> Metadata> C Migrationv @ Pipedrive> D OpportunitySyncStrategy> D ProspectSearchStrategy© ApiFields.php© Client.php© FieldDefinitions.php© PipedriveApiClient.php© PipedriveApiException.php private function buildCallPayload(Activity $activity): Activity$data = ['done' => 1,'user_id' => $this->profile->crm_provider_id,'due_time' => $activity->scheduled_start_time->format( format: 'H:i'),'due_date' => $activity->scheduled_start_time->toDateString(),'duration' => gmdate( format:'H:i', $activity->duration),'type' => $type->value,'subject' => $this->generateActivityTitle(Sactivity),'note' => $this->generateActivityDescription(Sactivity),] + $this->fetchCustomFieldData(Sactivity,objectType: Field::0BJECT_TASK)+ $this->convertActivityAssociations($activity);return $this->upsertActivity(Sactivity, $data);© Service.php© TokenStorage.php1 usage1533private function buildTextMessagePayload(Activity $activity): Activity{...}v 0 Salesforce> M Fioldo1552Workspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)D211212$type = $this->getCrmType($activity->category);= 213if ($type ===null){throw new Exception( message:'No mapped CRM type in Pipedrive for "' . $activity->category->name5214=216=217= 218219220III221.222— 223|= 224|-225-226=227: 228_229230=231— 232=233 v=2341513:29Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASEWHENidFROMsocial,1 on u.id= sa.:1..n<->1: on t.Lid= 1052 andIMaccounts wheiactivitiesW Windsurf TeamsUTF-8Co 4 spaces...
|
45291
|
NULL
|
NULL
|
NULL
|
|
45292
|
1624
|
19
|
2026-05-14T14:29:50.127926+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768990127_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday at 13.31Yesterdav at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterdav at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Voctorday at 12:22Yesterday at 13.21Yesterdav at 13:21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterdav at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yoctorday at 12:10Yesterdav at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterdav at 13:08Yesterdav at 13:07MPEG-4 movie13 KB16 KB1/ Kb29 KE6 KB12 KB9 K:MPEG-4 movieMPEG-4 movieMPEG-4 movieMPE0"4 movie7 KBMDSG-A movid8 KE37 KBMP2G-4 movie10 KB7 KBMPEG-4 movieMPEG"4 movie9K:8 KBMoECA movio72 KB14 K8|MPEG-4 movie13 KRI9 KB18 KBMPEG-1 movidMPEG-4 movie12 KEMPEG-4 movie10 KB16 KBMPEG-4 movie6 KB1MPEG-4 movieMDSG.A movic12 KB23 KBMPEG-4 movie8 KE6 KBMPEG-4 movieMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KPMDEC A movid34 KBMPEG-4 movie10 KPMDEG-A movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBI26 KB111 KB102 KBMPEG-4 movieMDEC.A movialMPEG-4 movieRR KR59 KB98 KBMDEG-A movieMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMDEG.A movio58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.53 GB availabld• Inu 14 Mау 1/.29.44Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
7534208664281949158
|
NULL
|
idle
|
ocr
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIđ 0Pmv Q SearchDate ModifiedYesterdav at 13.36Yesterday at 13:35Yesterday at 13.34Yesterdav at 13:34Yesterday at 13:33resterday at 15.5sYesterdav at 13:37Yesterday at 13:32Yesterday at 13.31Yesterdav at 13:30Yesterday at 13:30resterday at 15.4gYesterdav at 13:29Yesterday at 13:28Yesterday at 13:28Yesterday at 13:27Yesterdav at 13:27resterday at 13-40Yesterdav at 13:25Yesterday at 13:25Yesterday at 13:24Yesterday at 13:24Voctorday at 12:22Yesterday at 13.21Yesterdav at 13:21Yesterdav at 13:20Yesterday at 13:20Yesterday at 13:19Yesterday at 13:19Yesterday at 13.18Vecterdav at 12:17Yesterday at 13:16Yesterdav at 13:16Yesterday at 13:15Yesterday at 13:14Yecterdav at 12:1/Yesterdav at 13:13Yesterday at 13.13Yecterdav at 12:12Yesterday at 13:12Yesterday at 13:11Yoctorday at 12:10Yesterdav at 13:10Yesterday at 13.09Yecterdav at 12:091Yesterdav at 13:08Yesterdav at 13:07MPEG-4 movie13 KB16 KB1/ Kb29 KE6 KB12 KB9 K:MPEG-4 movieMPEG-4 movieMPEG-4 movieMPE0"4 movie7 KBMDSG-A movid8 KE37 KBMP2G-4 movie10 KB7 KBMPEG-4 movieMPEG"4 movie9K:8 KBMoECA movio72 KB14 K8|MPEG-4 movie13 KRI9 KB18 KBMPEG-1 movidMPEG-4 movie12 KEMPEG-4 movie10 KB16 KBMPEG-4 movie6 KB1MPEG-4 movieMDSG.A movic12 KB23 KBMPEG-4 movie8 KE6 KBMPEG-4 movieMPEG-4 movie6 KBMPEG-4 movie11 K:MPEG-4 movie11 KPMDEC A movid34 KBMPEG-4 movie10 KPMDEG-A movie7 KBMPEG-4 movie5 KBMPEG-4 movie11 KBI26 KB111 KB102 KBMPEG-4 movieMDEC.A movialMPEG-4 movieRR KR59 KB98 KBMDEG-A movieMPEG-4 movie07 K:MPEG-A movie66 KB MPEG-4 movie44 KB93 KB79 KpMPEG-4 movieMDEG.A movio58 KBMPEG-4 movie27 KP7 KBMPEG-4 movie12 KB32 K8MPEG-4 movieAl Notes: OffLeave• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfKoválik Family Tree.gedbitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.53 GB availabld• Inu 14 Mау 1/.29.44Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45291
|
1623
|
29
|
2026-05-14T14:29:49.374602+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768989374_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.73194444,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"bounds":{"left":0.75277776,"top":0.3122222,"width":0.023611112,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.78055555,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45288
|
1623
|
27
|
2026-05-14T14:29:13.865623+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768953865_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.73194444,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"bounds":{"left":0.75277776,"top":0.3122222,"width":0.023611112,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.78055555,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45287
|
1623
|
26
|
2026-05-14T14:29:09.170925+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768949170_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.73194444,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"bounds":{"left":0.75277776,"top":0.3122222,"width":0.023611112,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.78055555,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"}]...
|
3621139147992205675
|
-6979357266689274933
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.js* #12066 on JY-20725-handle-HS-search-rate-limit k vabl100% СHandleHubspotRateLimitTest* :8• Thu 14 May 17:29:08QProject v© ReportController.php© AutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.phpPlaybackController.php:=custom.log© DecorateActivity.php© UserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php=laravel.log© LocalSearch.php• LocalSearchlnterface.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.phpSF [jiminny@localhost]© RemoteSearch.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.phpA HS_local [jiminny@localho:W© Service.php© ActivityPlaybookTrait.phpCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging4 console [QAI PROD] X18v C Listeners© ConvertLeadActivities.phpE.envDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php4 console [PROD]© PurgeLookupCache.php› D Metadata© HubspotPaginationService.php© HandleHubspotRateLimit.phpA console (EU]50classService extends BaseService implements> C MigrationA5 A119 X3 X9 ^DGo jiminny1511private function buildCallPayload(Activity $activity): Activityv Pipedrive0841A3 X4 A V1512> D OpportunitySyncStrategy211m migrations oi1513$type = $this->getCrmType($activity->category);> D ProspectSearchStrategy1514212if ($type === null) {© ApiFields.php=2131515'No mapped CRM type in Pipedrive for "* . Sactivity->category-›name=214m teams wherethrow new Exception( message:© Client.phpm crm_layouts !1516© FieldDefinitions.php215iM crm_layout_ei1517=© PipedriveApiClient.php216IM crm_fields Wi1518$data = [© PipedriveApiException.php=2171519'done' => 1,© Service.php= 218m features;1520'user_id' => $this->profile->crm_provider_id,© TokenStorage.php1521219m team_feature:'due_time' => $activity->scheduled_start_time->format( format: 'H:i'),v D Salesforce220m opportunitie:1522|> C Fields'due_date' => $activity->scheduled_start_time->toDateString(),III2211523'duration' => gmdate( format: 'H:i', $activity->duration),> OpportunityMatcher222m teams;1524'type' => $type->value,> C OpportunitySyncStrategy1525223'subject' => $this->generateActivityTitle(Sactivity),> D ProspectSearchStrategy1526= 224|1.id, CASEWHENv D ServiceTraits'note' => $this->generateActivityDescription(Sactivity),-2251527] + $this->fetchCustomFieldData(Sactivity,objectType: Field::0BJECT_TASK)T BatchSyncTrait.php2261528+ $this->convertActivityAssociations($activity);T FollowupActivityTrait.php1529=227idFROMsocial,D LogActivityTrait.php1530-- 2281 on u.id= sa.:T RecordManipulationsTraireturn Sthis-›upsertActivity(Sactivity, $data);15311|2291..n<->1: on t.T SyncFieldsTrait.php2301532Lid = 1052 and=© Client.php2311 usage© DecorateActivity.php— 232IMaccounts whei1533private function buildTextMessagePayload(Activity $activity): Activity{...}DeleteObjectsTrait.php=233 vactivities1552© FieldDefinitions.php2 usages=234© PavinadRuilder nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams1525:43UTF-8Co 4 spaces...
|
45284
|
NULL
|
NULL
|
NULL
|
|
45286
|
1624
|
17
|
2026-05-14T14:29:05.034858+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768945034_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.33477393,"top":1.0,"width":0.122340426,"height":-0.019952059},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.5731383,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.5884308,"top":1.0,"width":0.076130316,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.66456115,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.67586434,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.6871675,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.7150931,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.72639626,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.73769945,"top":1.0,"width":0.011303191,"height":-0.019952059},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","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\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false}]...
|
3865544371963712397
|
-7980155751442543530
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45285
|
1624
|
16
|
2026-05-14T14:29:01.720690+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768941720_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.2962101,"top":1.0,"width":0.03856383,"height":-0.019952059},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8043719072324535154
|
-8628527368849355612
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
Jiminny... ~ActivityFiles Project: faVsco.js, menu
Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeavev Q SearchDate ModifiedYesterdav at 13.35Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:33Yesterday at 13:32Yesterday at 13:32Yesterday at 13:31Yesterday at 13.30Yesterdav at 13:29Yesterday at 13:29resterday at 15.2oYesterdav at 13:28Yesterday at 13:27Yesterday at 13:26Yesterdav at 13:26resterday at 13-40Yesterdav at 13:24Yesterday at 13:24Yesterday at 13:23Yesterday at 13.20Yesterdav at 13:20Yesterdav at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13.17Yecterdav at 12:16Yesterdav at 13:16Yesterday at 13.10Yesterdav at 13:15Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterdav at 13:12Yesterday at 13.12Yesterdav at 13:11Yesterday at 13:11Yesterday at 13:10Yoctorday at 12:00Yesterdav at 13:09Yesterday at 13.08Yocterdav at 12:08Yesterdav at 13:07Yesterdav at 13:0616 K:MPEG-4 movie17 KB29 KBMDEG-A movieMPEG-4 movieMPEG-4 movie12 KE9 KBMPE0"4 movie8 K:37 KB10 KBIKBnMDSG-A movieMP2G-4 movie8 KBI9 KB8 KB72 K:MPEG-4 movieMPEG-4 movie14 KB13 KBMoECA movieMPEG-4 movie18 KBI12 KB10 KBMPEG-1 movidMPEG-4 movie16 KEMPEG-4 movie6 KB MPEG-4 movie6 KB12 KB|MPEG-4 movie23 KBMDSG.A movicMPEG-4 movie6KE11 KBMPEG-4 movieMPEG-4 movie11 KBMPEG-4 movie20 K:MPEG-4 movie2AKR10 KB7 KBMDEGA movidMPEG-4 movie5KPMDEG-A movie11 KBMPEG-4 movie26 KBMPEG-4 movie111 K:102 KB88 KB59 KBMPEG-4 movieMDEC.A moviaMPEG-4 movieO9 KR97 KB66 KBMDEG-A movieMPEG-4 movie44K:MPSG-A movid93 KBMPEG-4 movie78 KB50 KB59 KPMPEG-4 movieMDEG.A movio27 KBMPEG-4 movie12 K:32 KBMDEC A movid17 KB19K8MPEG-4 movie• • CFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfP Kdf-a,k Ffmiv Tree.sebitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esv1 config.vmlIteration run Search HS.postman_collection.jsonm licence bettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•AlfredwPmazanoko.imnaoc.YWIfана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.53 GB availabld• Inu 14 May 1/.29:0.Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45282
|
NULL
|
NULL
|
NULL
|
|
45284
|
1623
|
25
|
2026-05-14T14:29:03.632493+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768943632_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12066 on JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.13472222,"top":0.027777778,"width":0.25555557,"height":0.035555556},"on_screen":true,"help_text":"Pull request #12066 exists for current branch JY-20725-handle-HS-search-rate-limit, but local branch is out of sync with remote","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.6326389,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"HandleHubspotRateLimitTest","depth":6,"bounds":{"left":0.6645833,"top":0.027777778,"width":0.15902779,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.82361114,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'HandleHubspotRateLimitTest'","depth":6,"bounds":{"left":0.8472222,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.87083334,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.9291667,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9527778,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9763889,"top":0.027777778,"width":0.023611112,"height":0.035555556},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"5","depth":4,"bounds":{"left":0.73194444,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"119","depth":4,"bounds":{"left":0.75277776,"top":0.3122222,"width":0.023611112,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.78055555,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.80138886,"top":0.3122222,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.8215278,"top":0.31,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.8368056,"top":0.31,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Pipedrive;\n\nuse Carbon\\Carbon;\nuse Devio\\Pipedrive\\Exceptions\\ItemNotFoundException;\nuse Devio\\Pipedrive\\Exceptions\\PipedriveException;\nuse Exception;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\PipedriveInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Events\\Users\\SocialAccountDisconnected;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\SocialAccountTokenInvalidException;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\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\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Log;\nuse UnexpectedValueException;\n\nclass Service extends BaseService implements\n PipedriveInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n RemoteEntityManipulationInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\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 const string CALL_HEADER = 'Jiminny Summary';\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)\n {\n parent::__construct();\n\n $this->client = $client;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Pipedrive';\n }\n\n /**\n * @inheritdoc\n */\n public function setUser(User $user): void\n {\n try {\n parent::setUser($user);\n } catch (InvalidArgumentException $exception) {\n // In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.\n if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&\n $exception->getMessage() === 'Required option not passed: \"access_token\"') {\n // Something terrible happened to their token, they need to reconnect.\n $this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);\n\n event(new SocialAccountDisconnected($this->client->oauthAccount));\n }\n\n throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');\n }\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n switch ($fieldType) {\n case Field::TYPE_DATE:\n $value = $internal ? Carbon::parse($fieldValue) : $fieldValue;\n\n break;\n\n default:\n $value = $fieldValue;\n }\n\n return $value;\n }\n\n /**\n * Gets the valid fields for a given Object via the property APIs.\n *\n * @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact\n */\n protected function getFields(string $crmObject): array\n {\n switch ($crmObject) {\n case 'task':\n $properties = $this->client->getInstance()->activityFields();\n\n break;\n\n case 'account':\n $properties = $this->client->getInstance()->organizationFields();\n\n break;\n\n case 'contact':\n $properties = $this->client->getInstance()->personFields();\n\n break;\n\n case 'opportunity':\n $properties = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException();\n }\n\n $fields = [];\n\n try {\n $response = $properties->all();\n\n if ($response->isSuccess() === false) {\n throw new Exception($response->getContent(), $response->getStatusCode());\n }\n\n foreach ($response->getData() as $property) {\n $fields[] = ['label' => $property->name, 'name' => $property->key];\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::taskFollowupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n return;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n $field->description = null;\n $field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($crmField->field_type);\n $field->is_selectable = $crmField->active_flag;\n $field->save();\n }\n }\n }\n\n /**\n * @param string $fieldType How the data should be input\n */\n private function convertFieldType(string $fieldType): string\n {\n $fieldType = strtolower($fieldType);\n\n switch ($fieldType) {\n case 'enum':\n case 'stage':\n case 'status':\n return Field::TYPE_PICKLIST;\n\n case 'varchar':\n return Field::TYPE_TEXT;\n\n case 'int':\n case 'double':\n return Field::TYPE_NUMBER;\n\n case 'text':\n return Field::TYPE_TEXTAREA;\n\n case 'monetary':\n return Field::TYPE_CURRENCY;\n\n case Field::TYPE_DATE:\n case Field::TYPE_TIME:\n case Field::TYPE_PHONE:\n return $fieldType;\n\n default:\n // This is not actually a supported field.\n return Field::TYPE_UNSUPPORTED;\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n switch ($field->object_type) {\n case Field::OBJECT_TASK:\n $crmFields = $this->client->getInstance()->activityFields();\n\n break;\n\n case Field::OBJECT_ACCOUNT:\n $crmFields = $this->client->getInstance()->organizationFields();\n\n break;\n\n case Field::OBJECT_CONTACT:\n $crmFields = $this->client->getInstance()->personFields();\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n $crmFields = $this->client->getInstance()->dealFields();\n\n break;\n\n default:\n throw new InvalidArgumentException('Invalid field type \"' . $field->object_type . '\" for field ' . $field->id . '.');\n }\n\n if ($field->crm_provider_id == 'stage_id') {\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n foreach ($pipelines as $pipeline) {\n $dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();\n foreach ($dealStages as $i => $dealStage) {\n $values = [\n 'value' => $dealStage->id,\n 'label' => $dealStage->name,\n 'sequence' => $i,\n ];\n\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $dealStage->id,\n ], $values);\n }\n }\n\n return $fieldValues;\n }\n\n // Sadly we must traverse the entire list because they don't allow to get an individual field by key.\n foreach ($crmFields->all()->getData() as $crmField) {\n if ($crmField->key === $field->crm_provider_id) {\n // Import all active values.\n if (property_exists($crmField, 'options')) {\n foreach ($crmField->options as $i => $option) {\n $values[] = [\n 'value' => $option->id,\n 'label' => $option->label,\n 'sequence' => $i,\n ];\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n }\n }\n\n return $fieldValues;\n }\n\n /**\n * @inheritdoc\n *\n * @throws SocialAccountTokenInvalidException\n * @throws PipedriveException\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // We create a business process to contain the pipeline, and later store all stages against it.\n $pipelines = $this->client->getInstance()->pipelines()->all()->getData();\n\n foreach ($pipelines as $pipeline) {\n $businessProcess = $this->importPipelineData($pipeline);\n\n $stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);\n if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {\n $missingStage = $stage;\n }\n }\n } catch (PipedriveException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n // Pipedrive doesn't use leads.\n return 0;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n // Pipedrive doesn't use leads.\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdAccounts = $this->client->getInstance()->organizations()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdAccounts->getData() === null) {\n return 0;\n }\n\n foreach ($pdAccounts->getData() as $pdAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($pdAccount->id)) {\n $this->importAccount($pdAccount);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n try {\n $hsAccount = $this->client->getInstance()->organizations()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importAccount($hsAccount->getData());\n }\n\n /**\n * @inheritdoc\n */\n private function importAccount($crmData): Account\n {\n $countryCode = $crmData->country_code;\n if ($countryCode === null && $crmData->address_country) {\n $countryCode = $this->convertCountryNameToCode($crmData->address_country);\n }\n\n $name = $crmData->name;\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n /**\n * Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.\n * These are real samples from API results:\n * { ... owner_id: 23696001, ...}\n * OR\n * {\n * ...\n * owner_id: {\n * active_flag: false,\n * id: 12972717,\n * name: 'name here',\n * value: 12972717\n * }\n * }\n */\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;\n\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($name, 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Account::class,\n fileName: (string) $crmData->id,\n avatarText: $name,\n ),\n 'country_code' => $countryCode,\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Account */\n return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n\n try {\n foreach ($strategies as $syncStrategy) {\n foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {\n $this->importOpportunity($pdOpportunity);\n $syncCount++;\n }\n }\n\n $this->logger->info('[Pipedrive] Sync Opportunities completed', [\n 'team' => $this->team->getId(),\n 'syncCount' => $syncCount,\n ]);\n } catch (Exception $exception) {\n $this->logger->info('[Pipedrive] Failed to sync Opportunities ', [\n 'team' => $this->team->getId(),\n 'params' => $parameters,\n 'strategy' => $strategy,\n 'reason' => $exception->getMessage(),\n 'syncedBeforeFailure' => $syncCount,\n ]);\n }\n\n if ($syncCount > 1000) {\n $this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [\n 'team' => $this->team->getId(),\n 'total' => $syncCount,\n ]);\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,\n );\n\n $parameters = ['crm_id' => $crmId];\n\n try {\n $generator = $strategy->fetchOpportunities($parameters);\n $pdOpportunity = $generator->current();\n } catch (ItemNotFoundException | PipedriveException $e) {\n $this->logger->info('[Pipedrive] Failed to sync opportunity', [\n 'crm_id' => $crmId,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n return $this->importOpportunity($pdOpportunity);\n }\n\n /**\n * @inheritdoc\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n if ($crmData->deleted) {\n return null;\n }\n\n if ($crmData->org_id) {\n $orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n $account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();\n\n if ($account === null) {\n $account = $this->syncAccount($orgId);\n\n if ($account === null) {\n // For some bizarre reason, the org_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);\n\n return null;\n }\n }\n } elseif ($crmData->person_id) {\n // Try to get the organization from the person.\n $personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;\n $contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();\n\n if ($contact === null || $contact->account_id === null) {\n $contact = $this->syncContact($personId);\n\n if ($contact === null) {\n // For some bizarre reason, the person_id is not importable from Pipedrive- bail.\n $this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);\n\n return null;\n }\n\n if ($contact->account_id === null) {\n $this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());\n\n return null;\n }\n }\n\n $account = $contact->account;\n } else {\n // If there is nothing to associate this with, don't import it.\n return null;\n }\n\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $crmData->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $crmData->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n\n $closeDate = null;\n if ($crmData->expected_close_date) {\n $closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');\n }\n\n $remotelyCreatedAt = Carbon::parse($crmData->add_time);\n\n $data = [\n 'team_id' => $this->team?->getId(),\n 'account_id' => $account?->getId(),\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->title, 0, 128),\n 'value' => $crmData->value,\n 'currency_code' => CurrencyFormatter::formatCode($crmData->currency),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData->close_time !== null,\n 'is_won' => $crmData->won_time !== null,\n 'stage_id' => $stage?->getId(),\n 'record_type_id' => $recordType?->getId(),\n 'remotely_created_at' => $remotelyCreatedAt,\n ];\n\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n $this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n\n try {\n $pdContacts = $this->client->getInstance()->persons()->all([\n 'limit' => 500,\n 'sort' => 'update_time DESC',\n ]);\n\n if ($pdContacts->getData() === null) {\n return 0;\n }\n\n foreach ($pdContacts->getData() as $pdContact) {\n // Only sync if previously imported.\n if ($this->hasContact($pdContact->id)) {\n $this->importContact($pdContact);\n $syncCount++;\n }\n }\n } catch (Exception $exception) {\n // Do nothing for now.\n }\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n try {\n $pdContact = $this->client->getInstance()->persons()->find($crmId);\n } catch (ItemNotFoundException | PipedriveException $e) {\n throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);\n }\n\n return $this->importContact($pdContact->getData());\n }\n\n private function importContact($crmData): Contact\n {\n $photoPath = null;\n $account = null;\n if ($crmData->org_id) {\n /**\n * Pipedrive API is inconsistent, it may return a string variable, or an object\n */\n $crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;\n\n $account = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmProviderId)\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmProviderId);\n $photoPath = $this->teamService->generateAvatar(\n $crmData->id,\n $crmData->name,\n );\n } else {\n $photoPath = $account->photo_path;\n }\n }\n\n if ($photoPath === null) {\n $photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: (string) $crmData->id,\n modelType: Contact::class,\n fileName: (string) $crmData->id,\n avatarText: $crmData->name,\n );\n }\n\n $ownerId = $profile = null;\n if ($crmData->owner_id) {\n $ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;\n $profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();\n }\n\n $parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);\n\n $mobileNumber = null;\n if (isset($crmData->phone[1])) {\n $mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);\n }\n\n $email = null;\n if (isset($crmData->email[0]->value)) {\n $email = mb_strimwidth($crmData->email[0]->value, 0, 191);\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile ? $profile->user_id : null,\n 'owner_id' => $ownerId,\n 'name' => mb_strimwidth($crmData->name ?? '', 0, 100),\n 'email' => $email,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'mobile_phone' => $mobileNumber ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'title' => $crmData->job_title ?? null,\n 'photo_path' => $photoPath,\n 'remotely_created_at' => Carbon::parse($crmData->add_time),\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 /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n // Not applicable.\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfiles(?User $userToSearch = null): ?\\Jiminny\\Models\\Crm\\Profile\n {\n // Not needed yet.\n return null;\n }\n\n protected function getFieldTypes(): array\n {\n return [\n parent::OBJECT_TASK,\n parent::OBJECT_ACCOUNT,\n parent::OBJECT_CONTACT,\n parent::OBJECT_OPPORTUNITY,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileFields(): void\n {\n $fieldTypes = [\n 'task',\n 'account',\n 'contact',\n 'opportunity',\n ];\n\n foreach ($fieldTypes as $fieldType) {\n try {\n $currentFields = $this->getFields(ucfirst($fieldType));\n\n $newFields = array_column($currentFields, 'name');\n $changedFields = array_merge(\n array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),\n array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),\n );\n\n if (\\count($changedFields) > 0) {\n $fields = implode(',', $newFields);\n Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);\n\n /*\n // If they have changed, raise a notification to the owner.\n $team->owner->notify(\n new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)\n );\n */\n\n $this->profile->{$fieldType . '_fields'} = $fields;\n }\n } catch (Exception $exception) {\n Log::error('No access to ' . $fieldType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n $this->profile->save();\n }\n\n /**\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 $params = [\n 'term' => $name,\n 'limit' => $count,\n 'start' => $offset,\n 'item_types' => 'person,organization',\n ];\n\n $objects = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $objects = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n if ($exception->getMessage() === 'Invalid token: access token is invalid') {\n throw new SocialAccountTokenInvalidException($exception->getMessage());\n }\n\n throw $exception;\n }\n\n if (empty($objects['items'])) {\n return [];\n }\n\n // Build mapped list.\n foreach ($objects['items'] as $object) {\n $object = $object['item'];\n\n // We only support these object types today.\n if (\\in_array($object['type'], ['person', 'organization']) === false) {\n continue;\n }\n\n $objectType = $object['type'] === 'person' ? 'contact' : 'account';\n $record = [\n 'crmId' => $object['id'],\n 'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),\n 'name' => $object['name'],\n 'industry' => null,\n 'title' => null,\n 'prospectType' => $objectType,\n 'phoneNumbers' => [],\n ];\n\n if (isset($object['organization']['name'])) {\n $record['organization'] = $object['organization']['name'];\n }\n\n if (isset($object['phones'][0])) {\n $parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);\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 $data[] = $record;\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 'only_primary_association' => 1,\n 'status' => 'open',\n 'sort' => 'update_time DESC',\n ];\n\n switch ($this->config->opportunity_assignment_rule) {\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:\n $params['sort'] = 'add_time DESC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:\n $params['sort'] = 'add_time ASC';\n\n break;\n\n case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:\n $params['sort'] = 'update_time DESC';\n $params['status'] = 'all_not_deleted';\n }\n\n $pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();\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 if ($pdOpportunities === null) {\n return [];\n }\n\n foreach ($pdOpportunities as $pdOpportunity) {\n // Stage and RecordType are part of BusinessProcess\n $businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);\n if ($businessProcess === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n }\n\n $stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);\n if ($businessProcess !== null && $stage === null) {\n $stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);\n }\n\n /** @var ?RecordType $recordType */\n $recordType = $businessProcess?->recordTypes()->first();\n if ($businessProcess !== null && $recordType === null) {\n $businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);\n $recordType = $businessProcess?->recordTypes()->first();\n }\n\n $record = [\n 'crmId' => $pdOpportunity->id,\n 'name' => $pdOpportunity->title,\n 'value' => $pdOpportunity->formatted_value,\n 'won' => $pdOpportunity->won_time !== null,\n 'closed' => $pdOpportunity->status !== 'open',\n 'stage' => [\n 'id' => $stage?->id_string ?? '',\n 'name' => $stage?->name ?? '',\n ],\n 'recordType' => [\n 'id' => $recordType?->id_string ?? '',\n 'name' => $recordType?->name ?? '',\n ],\n ];\n\n if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n $pdActivities = null;\n $filters = ['done' => 0];\n switch ($objectType) {\n case 'contact':\n $pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();\n\n break;\n case 'account':\n // Prioritize looking up activities on the deal (which should be linked on the account anyway).\n if ($opportunityId) {\n $pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();\n }\n\n // If no deal activity, fall back to the organization.\n if ($pdActivities === null) {\n $pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();\n }\n\n break;\n }\n\n if ($pdActivities === null) {\n return [];\n }\n\n foreach ($pdActivities as $pdActivity) {\n // Only import active, scheduled tasks in the future.\n if ($pdActivity->active_flag) {\n $due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');\n $data[] = [\n 'crmId' => $pdActivity->id,\n 'subject' => $pdActivity->subject,\n 'due' => Carbon::parse($due)->toIso8601String(),\n 'type' => $pdActivity->type,\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 $contact = null;\n $account = null;\n\n $params = [\n 'term' => $email,\n 'item_types' => 'person',\n 'fields' => 'email',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $exception) {\n $this->logger->info('[Pipedrive] Email match failed', [\n 'email' => $email,\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if (\n ! $contact instanceof Contact\n && ! $account instanceof Account\n ) {\n return null;\n }\n\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n if (count($pdOpportunities) > 0) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 return null;\n }\n\n $params = [\n 'term' => $phone,\n 'item_types' => 'person,organization',\n 'fields' => 'phone',\n ];\n\n $searchResult = [];\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $searchResult = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {\n return null;\n }\n\n $contact = $this->syncContact($searchResult['items'][0]['item']['id']);\n $account = $contact->account;\n\n $countryCode = $this->getCountryCode($contact, $account);\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n private function getCountryCode($contact, $account): ?string\n {\n if ($contact && $contact->country_code) {\n return $contact->country_code;\n } elseif ($account && $account->country_code) {\n return $account->country_code;\n }\n\n return null;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {\n $contact = null;\n $account = null;\n\n $params = [\n 'term' => $name,\n 'item_types' => 'person',\n 'fields' => 'name',\n ];\n\n $pdContact = null;\n\n try {\n if (! $this->client instanceof Client) {\n throw new \\InvalidArgumentException('Expected Pipedrive Client instance');\n }\n $response = $this->client->searchItems($params);\n $pdContact = $response['data'] ?? null;\n } catch (PipedriveApiException $e) {\n $this->logger->info('[Pipedrive] Name match failed', [\n 'name' => $name,\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {\n $contact = $this->syncContact($pdContact['items'][0]['item']['id']);\n $account = $contact->account;\n }\n\n if ($contact || $account) {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $pdOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId,\n );\n } catch (Exception $e) {\n $pdOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($pdOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n return false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @inheritdoc\n */\n public function saveActivity(Activity $activity): Activity\n {\n switch ($activity->type) {\n case Activity::TYPE_CONFERENCE:\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n $activity = $this->buildCallPayload($activity);\n\n break;\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n $activity = $this->buildTextMessagePayload($activity);\n\n break;\n }\n\n return $activity;\n }\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'person_id' => $activity->hasContact() ? $activity->getContact()->getCrmProviderId() : null,\n 'org_id' => $activity->hasAccount() ? $activity->getAccount()->getCrmProviderId() : null,\n 'deal_id' => $activity->hasOpportunity() ? $activity->getOpportunity()->getCrmProviderId() : null,\n ];\n }\n\n private function buildCallPayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->scheduled_start_time->format('H:i'),\n 'due_date' => $activity->scheduled_start_time->toDateString(),\n 'duration' => gmdate('H:i', $activity->duration),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->fetchCustomFieldData($activity, Field::OBJECT_TASK)\n + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function buildTextMessagePayload(Activity $activity): Activity\n {\n $type = $this->getCrmType($activity->category);\n if ($type === null) {\n throw new Exception('No mapped CRM type in Pipedrive for \"' . $activity->category->name . '\"');\n }\n\n $data = [\n 'done' => 1,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_time' => $activity->created_at->format('H:i'),\n 'due_date' => $activity->created_at->toDateString(),\n 'type' => $type->value,\n 'subject' => $this->generateActivityTitle($activity),\n 'note' => $this->generateActivityDescription($activity),\n ] + $this->convertActivityAssociations($activity);\n\n return $this->upsertActivity($activity, $data);\n }\n\n private function upsertActivity(Activity $activity, array $data): Activity\n {\n // The activity should be logged under an existing activity.\n if ($activity->hasCrmProviderId()) {\n $existingActivity = $this->client->getActivity($activity->getCrmProviderId());\n\n if ($existingActivity && $existingData = $existingActivity->getData()) {\n $existingData = (array) $existingData;\n $data = $this->updateDescription($existingData, $data);\n $data = $this->updateProspectData($existingData, $data);\n\n // Just update the existing engagement with any fresh details.\n $this->client->updateActivity($activity->getCrmProviderId(), $data);\n }\n } else {\n $pdActivity = $this->client->createActivity($data);\n\n $activity->crm_provider_id = $pdActivity->getData()->id;\n $activity->save();\n }\n\n return $activity;\n }\n\n private function updateDescription(array $crmActivityData, array $payloadData): array\n {\n $crmActivityNote = $crmActivityData['note'] ?? null;\n\n if (! empty($crmActivityNote)) {\n if (! str_contains($crmActivityNote, self::CALL_HEADER)) {\n $payloadData['note'] = $crmActivityNote . \"\\n\\n\" . $payloadData['note'];\n } else {\n $payloadData['note'] = $crmActivityNote;\n }\n }\n\n return $payloadData;\n }\n\n private function updateProspectData($crmActivityData, array $data): array\n {\n $changedData = [];\n if ($crmActivityData['person_id'] && ! isset($data['person_id'])) {\n $changedData['person_id'] = $crmActivityData['person_id'];\n $data['person_id'] = $crmActivityData['person_id'];\n $data['org_id'] = null;\n $data['deal_id'] = null;\n } elseif (isset($data['person_id']) && $data['person_id'] != $crmActivityData['person_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in person_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['person_id'],\n 'crm_activity_data' => $crmActivityData['person_id'],\n ]);\n }\n\n if ($crmActivityData['org_id'] && ! isset($data['org_id'])) {\n $changedData['org_id'] = $crmActivityData['org_id'];\n $data['org_id'] = $crmActivityData['org_id'];\n $data['deal_id'] = null;\n } elseif (isset($data['org_id']) && $data['org_id'] != $crmActivityData['org_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in org_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['org_id'],\n 'crm_activity_data' => $crmActivityData['org_id'],\n ]);\n }\n\n if ($crmActivityData['deal_id'] && ! isset($data['deal_id'])) {\n $changedData['deal_id'] = $crmActivityData['deal_id'];\n $data['deal_id'] = $crmActivityData['deal_id'];\n } elseif (isset($data['deal_id']) && $data['deal_id'] != $crmActivityData['deal_id']) {\n $this->logger->info('[Pipedrive] Prospect conflict in deal_id', [\n 'activity_id' => $crmActivityData['id'],\n 'local_data' => $data['deal_id'],\n 'crm_activity_data' => $crmActivityData['deal_id'],\n ]);\n }\n\n if (! empty($changedData)) {\n $this->logger->info('[Pipedrive] Updated activity associations', $changedData);\n }\n\n return $data;\n }\n\n private function generateActivityTitle(Activity $activity): string\n {\n if ($activity->hasTitle()) {\n $title = $activity->title;\n } elseif ($activity->playbook_category_id) {\n $title = $activity->category->name;\n } else {\n $title = 'Conference Call';\n }\n\n return $title;\n }\n\n private function generateActivityDescription(Activity $activity): string\n {\n $description = sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n self::CALL_HEADER\n );\n\n switch ($activity->type) {\n case Activity::TYPE_SOFTPHONE:\n case Activity::TYPE_SOFTPHONE_INBOUND:\n case Activity::TYPE_CONFERENCE:\n\n if ($activity->hasTitle()) {\n // Insert the activity type since there is no native field.\n $description .= sprintf(\n '<p><strong><span style=\"font-size: 18.6667px; line-height: 22.4px;\">%s</span></strong></p>',\n $activity->category->name,\n );\n }\n\n if ($activity->isTypeSoftPhone()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n // Tell them which number was dialed.\n if ($activity->to) {\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Dialed: %s</span></p>',\n $activity->to->phone_number,\n );\n }\n $description .= '<p></p>';\n }\n\n if ($activity->isTypeSoftphoneInbound()) {\n // Dialer calls have no native field for the subject.\n if ($activity->title) {\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->title,\n );\n }\n\n // Conference meetings log duration into a distinct field, so log this in the main text.\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Call Duration: %s</span></p>',\n $activity->duration_for_humans,\n );\n\n $description .= sprintf(\n '<p><span style=\"font-size: 13.3333px; line-height: 16px;\">Caller: %s</span></p>',\n $activity->from->phone_number,\n );\n\n $description .= '<p></p>';\n }\n\n if ($activity->hasReasonCodeBotKicked()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker removed from this meeting'\n . '</span>'\n . '</p>';\n // When we fix the state to be Activity::RECORDING_RECORDED as it should be this can change.\n } elseif ($activity->hasReasonCodeNotCompliant()) {\n $description .= '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . 'Notetaker did not join due to recording consent not being provided by attendees'\n . '</span>'\n . '</p>';\n } elseif ($activity->canReviewActivity()) {\n $playbackUrl = $activity->user->team->partner->getPlaybackUrl($activity);\n $description .= sprintf(\n '<p>'\n . '<span style=\"font-size: 13.3333px; line-height: 16px;\">'\n . '<a href=\"%s\" target=\"_blank\">Review in Jiminny</a>'\n . ' ▶️'\n . '</span>'\n . '</p>',\n $playbackUrl,\n );\n }\n\n if ($activity->type === Activity::TYPE_CONFERENCE) {\n $description .= '<p><strong>Attendees</strong></p><ul>';\n\n // Only include participants that joined.\n $participants = $activity->participants()->ghost(false)->whereNotNull('enter_time')->get();\n foreach ($participants as $participant) {\n $attendee = '<li>';\n if ($participant->name) {\n $attendee .= $participant->name;\n if ($participant->phone_number) {\n $attendee .= ' (' . $participant->phone_number . ')';\n } elseif ($participant->email) {\n $attendee .= ' (' . $participant->email . ')';\n }\n } else {\n $attendee .= $participant->phone_number;\n }\n\n if ($participant->is_ghost) {\n $attendee .= ' <strong>coach</coach>';\n }\n\n if ($participant->getTitle()) {\n $attendee .= ', ' . $participant->getTitle();\n }\n\n $description .= $attendee . '</li>';\n }\n $description .= '</ul>';\n }\n\n if (\\count($activity->notes) > 0) {\n $description .= '<p><strong>Notes</strong></p>';\n\n foreach ($activity->notes as $note) {\n $time = ($note->time > 3600) ? gmdate('H:i:s', $note->time) : gmdate('i:s', $note->time);\n $description .= '<p>' . $time . ' ' . $note->note . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all private messages.\n $messages = $activity->messages()\n ->where('is_private', 1)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Coaching Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n // Get all public messages.\n $messages = $activity->messages()\n ->where('is_private', 0)\n ->orderBy('created_at', 'asc');\n\n if ($messages->count() > 0) {\n $description .= '<p><strong>Customer Chat</strong></p>';\n\n foreach ($messages->get() as $message) {\n $description .= '<p>' . $message->participant->name . ': ' . $message->message . '</p>';\n }\n $description .= '</p>';\n }\n\n if ($activity->summary) {\n $description .= '<p><strong>Summary</strong></p><p>' . $activity->summary . '</p><p></p>';\n }\n\n break;\n\n case Activity::TYPE_SMS_INBOUND:\n case Activity::TYPE_SMS_OUTBOUND:\n // Use Rich-text formatting.\n $description = sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\"><strong>%s</strong></span></p>',\n $activity->title,\n );\n $description .= sprintf(\n '<p><span style=\"font-size: 16px; line-height: 19.2px;\">%s</span></p>',\n $activity->description,\n );\n\n break;\n }\n\n return $description;\n }\n\n public function saveFollowupActivity(Activity $activity, array $fields): ?string\n {\n // This is the user provided activity type field.\n if (empty($fields['type'])) {\n return null;\n }\n\n if (empty($fields['subject'])) {\n // We need to convert the name correctly.\n $playbook = $this->getPlaybook($activity->getUser());\n $type = $playbook->activityField->values()->where('value', $fields['type'])->first();\n $subject = ($type->label ?? $fields['type']) . ' with ' . $activity->prospect_name;\n } else {\n $subject = $fields['subject'];\n }\n\n $data = [\n 'done' => 0,\n 'user_id' => $this->profile->crm_provider_id,\n 'due_date' => $fields['due_date'] ?? date('Y-m-d'),\n 'due_time' => $fields['due_time'] ?? null,\n 'subject' => $subject,\n 'type' => $fields['type'],\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->activities()->add($data);\n\n // We don't actually create a corresponding activity object on our side yet.\n return $pdActivity->getData()->id;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, self::NOTE_BODY_MAX_LENGTH);\n\n $data = [\n 'content' => $transcripts,\n ] + $this->convertActivityAssociations($activity);\n\n $pdActivity = $this->client->getInstance()->notes()->add($data);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $pdActivity->getData()->id;\n $transcription->save();\n }\n\n /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $this->convertObjectTypeToResource($objectType)->update($objectId, $data);\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n $record = $this->convertObjectTypeToResource($objectType)->find($objectId)->getData();\n\n // We could map the fields requested here before returning.\n return $record ? (array) $record : [];\n }\n\n private function convertObjectTypeToResource(string $objectType)\n {\n switch ($objectType) {\n case parent::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals();\n\n case parent::OBJECT_CONTACT:\n return $this->client->getInstance()->persons();\n\n case parent::OBJECT_ACCOUNT:\n return $this->client->getInstance()->organizations();\n\n case parent::OBJECT_TASK:\n return $this->client->getInstance()->activities();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \".$objectType.\"');\n }\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, [\n 'stage_id' => $stage->crm_provider_id,\n ]);\n }\n\n private function fetchCustomFieldData(Activity $activity, string $objectType): array\n {\n $payload = [];\n\n $fieldData = FieldData::where([\n 'object_id' => $activity->id,\n ])->whereHas('field', function ($query) use ($objectType) {\n $query->where('object_type', $objectType);\n })->get();\n\n foreach ($fieldData as $data) {\n // Add the field and value to the payload.\n $payload += [\n $data->field->crm_provider_id => $data->value,\n ];\n }\n\n return $payload;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n switch ($objectType) {\n case 'account':\n $url = $this->config->crm_base_url . '/organization/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $this->config->crm_base_url . '/person/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $this->config->crm_base_url . '/deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n // Sadly we can't deeplink to these in Pipedrive 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 $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n public function importPipeline(int $pipelineId): ?BusinessProcess\n {\n try {\n $pipeline = $this->client->getInstance()->pipelines()->find($pipelineId)->getData();\n } catch (ItemNotFoundException $exception) {\n return null;\n }\n\n if ($pipeline === null) {\n return null;\n }\n\n return $this->importPipelineData($pipeline);\n }\n\n private function importPipelineData(mixed $pipeline): BusinessProcess\n {\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $pipeline->active,\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $pipeline->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($pipeline->name, 0, 150),\n 'is_selectable' => $pipeline->active,\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n return $businessProcess;\n }\n\n private function importStagesForBusinessProcess(BusinessProcess $businessProcess, ?string $missingId): ?Stage\n {\n $missingStage = null;\n $stages = [];\n $dealStages = $this->client->getInstance()\n ->stages()\n ->all(['pipeline_id' => (int) $businessProcess->crm_provider_id])\n ->getData();\n\n foreach ($dealStages as $dealStage) {\n // Upsert all stages for the team.\n $stage = $this->importStage($dealStage);\n\n if ($missingId !== null && $stage->crm_provider_id == $missingId) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n\n return $missingStage;\n }\n\n private function importStage(mixed $dealStage): Stage\n {\n return $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $dealStage->id,\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($dealStage->name, 0, 50),\n 'label' => mb_strimwidth($dealStage->name, 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $dealStage->order_nr,\n 'is_selectable' => $dealStage->active_flag,\n 'probability' => $dealStage->deal_probability,\n ]);\n }\n\n private function getBusinessProcesses(string $pipelineId): ?BusinessProcess\n {\n return $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $pipelineId)\n ->first();\n }\n\n private function getStage(?BusinessProcess $businessProcess, string $stageId): ?Stage\n {\n return $businessProcess\n ?->stages()\n ->where('crm_provider_id', $stageId)\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n if (! is_numeric($crmProviderId)) {\n $this->logger->warning('[Pipedrive] Invalid activity ID format', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n\n $activity = $this->client->getActivity($crmProviderId);\n\n return ($activity->active_flag ?? false) === true;\n } catch (HttpNotFoundException) {\n // Activity not found in CRM - this is expected and permanent\n $this->logger->info('[Pipedrive] Activity not found during verification', [\n 'activity_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.85486114,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.87291664,"top":0.31,"width":0.018055556,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.9138889,"top":0.31,"width":0.059027776,"height":0.026666667},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"8","depth":4,"bounds":{"left":0.8645833,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.8854167,"top":0.34444445,"width":0.015277778,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.9048611,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"4","depth":4,"bounds":{"left":0.92569447,"top":0.34444445,"width":0.016666668,"height":0.02111111},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.9458333,"top":0.3422222,"width":0.015277778,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.9611111,"top":0.3422222,"width":0.014583333,"height":0.025555555},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","depth":4,"on_screen":true,"value":"SELECT\n# DISTINCT\nopp.id as opp_id, opp.uuid, opp.name,\n# COUNT(dr.id) AS deal_risk_count,\n\n# dr.id,\n# cfd.value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\n# LEFT JOIN group_deal_risk_types gdrt ON gdrt.group_id = usr.group_id\n# LEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id AND dr.group_deal_risk_type_id = gdrt.id\n\nLEFT JOIN crm_field_data cfd ON (cfd.object_id = opp.id and cfd.crm_field_id = 66814)\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 1\nAND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\nGROUP BY opp.id, cfd.value\nORDER BY\n# cfd.value\n CAST(cfd.value AS UNSIGNED)\n# owner_name\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1\n# AND gdrt.is_enabled = 1)\nDESC\nLIMIT 25\n# OFFSET 0\n;\n\nselect * from crm_fields where crm_configuration_id = 1 and object_type = 'opportunity';\nselect * from crm_layout_entities where crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4584045;\nSELECT * FROM crm_field_data WHERE object_id = 4584045;\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id = '0061K00000lfb9IQAQ'\n\nSELECT\n opp.id as opportunity_id,\n opp.name,\n COUNT(dr.id) as risk_count\nFROM opportunities opp\nLEFT JOIN deal_risks dr ON dr.opportunity_id = opp.id\nWHERE opp.id IN (\n 6069574,8900938,9507638,9524799,9660490,9662824,9705675,9749758,4158460,4391812,6450439,4448422,5118945,5590675,4584045,7228149,9002408,9165534,9446720,9641778,9665149,9703344,9709280,5747948,4158491,6182565,6263970,5798120,5111315,4536978,6062352,6548383,6072095,6548225,4480986,5011422,6548381,8760540,8917554,9509986,9514392,9569009,9569011,9578490,9713604,5784397,7276612,7288405,8994421,9118219,9608148,9818911,4510535,6479693,5958049,6271674,6550448,4158331,4158483,6126571,6171615,6540943,4897466,5190896,5796182,5932762,8572433,8723698,8892697,9711001,9789264,4549188,6100831,6170064,6260799,6263653,6449936,6530871,6538978,7777651,8059269,8319918,8787049,8901150,9263153,9453207,9514738,9696700,9791062,5752018,6421452,7439134,7878923,9354763,9369285,9514396,9582506,9889949,9890216,4094311,4158495,4158496,6098128,5585661,3872564,6442149,5891604,6164746,6199593,6583474,6519684,9018490,9809006,4496897,5041324,5829430,6198319,6253504,6555763,7242914,7931055,8024125,8797814,8058559,8673347,8892695,8994420,9616219,9714970,9722004,9809439,9818918,6523177,8134147,9002915,9711422,9892713,9901719,9954210,9978435,5800810,6243518,6416114,6222251,6411974,6512456,5791953,6545606,9914780,5805540,6238986,6463838,6547680,9767049,9809437,9810885,9890855,6673493,9902036,4335521,6379871,6503799,6546077,8018765,9907556,9958433,9905855,9916179,9946741,9957877,5563344,6271838,6450815,7641128,7762567,7780592,8684810,8685786,8685787,8685788\n)\nGROUP BY opp.id;\n\nSELECT COUNT(dr.id)\n FROM deal_risks dr\n JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n WHERE dr.opportunity_id = 5563344\n# AND gdrt.group_id = usr.group_id\n\n\n\nEXPLAIN SELECT\n opp.id as opp_id, opp.uuid, opp.name,\n\ncfd.value,\ncfv.sequence,\ncfv.value,\n# MAX(cfd.value) AS max_cfd_value,\nusr.name AS owner_name,\nopp.value, opp.currency_code, opp.close_date, opp.remotely_created_at, opp.is_closed, opp.is_won,\nusr.uuid as owner_uuid,\nusr.photo_path as owner_photo,\nusr.id AS owner_id,\njt.name as owner_job,\nopp.stage_id, opp.stage_updated_at,\nacc.name AS acc_name, opp.stage_updated_at, acc.crm_provider_id AS acc_provider_id, opp.crm_provider_id AS opp_provider_id,\nrt.business_process_id AS pipeline_id\nFROM opportunities opp\n\nLEFT JOIN record_types rt ON opp.record_type_id = rt.id\nLEFT JOIN users usr ON opp.user_id = usr.id\nLEFT JOIN accounts acc ON opp.account_id = acc.id\nLEFT JOIN job_titles jt ON usr.job_title_id = jt.id\n\nLEFT JOIN crm_field_data cfd ON cfd.id = (SELECT sub_cfd.id\n FROM crm_field_data sub_cfd\n WHERE sub_cfd.object_id = opp.id\n AND sub_cfd.crm_field_id = 66810\n ORDER BY sub_cfd.updated_at DESC\n LIMIT 1)\n\nLEFT JOIN crm_field_values cfv ON cfv.crm_field_id = 66810 AND cfv.value = cfd.value\n\nWHERE opp.user_id IS NOT NULL\nAND opp.deleted_at IS NULL\nAND opp.is_closed = 0\n# AND opp.is_closed = 1\n# AND opp.is_won = 0\nAND opp.close_date >= '2024-01-01 00:00:00'\nAND opp.close_date <= '2024-12-31 23:59:59'\nAND usr.team_id = 1\n\n# and opp.id = 4823179\n\n# GROUP BY opp.id\nORDER BY\n cfv.sequence DESC,\n# opp.name\n# owner_name\n cfd.value\n# CAST(cfd.value AS UNSIGNED)\n# CAST(MAX(cfd.value) AS UNSIGNED)\n# (SELECT COUNT(dr.id)\n# FROM deal_risks dr\n# JOIN group_deal_risk_types gdrt ON dr.group_deal_risk_type_id = gdrt.id\n# WHERE dr.opportunity_id = opp.id\n# AND dr.is_active = 1 AND gdrt.is_enabled = 1)\nDESC\n# LIMIT 75, 25\n;\n\n\n\nSELECT * FROM crm_field_values WHERE crm_field_id = 66810;\n\nSELECT f.id, f.crm_provider_id AS field_name, f.label, fd.object_id AS dealId, fd.value, fd.updated_at, fd.id, fd.created_at\nFROM crm_fields f\nINNER JOIN crm_field_data fd ON fd.crm_field_id = f.id\n WHERE (f.crm_configuration_id = 1)\n AND (f.object_type = 'opportunity')\n# AND (fd.object_id IN (4158331,4158483,4158495,4158496,6069574,6263970,6171615,6540943,6545606,5829430,6198319,6263653,6548381,7242914,8797814,7228149,7439134,7878923,8134147,9002915,9118219,9165534,9354763,9369285,9446720))\n AND (fd.object_id IN (4158460))\n AND (f.crm_provider_id IN ('ForecastCategoryName'))\n ORDER BY fd.object_id ASC, fd.updated_at DESC;\n\nselect * from crm_layouts where crm_configuration_id = 1;\nselect cf.* from crm_fields cf\njoin crm_layout_entities cle on cf.id = cle.crm_field_id\nwhere crm_layout_id = 1493;\n\nSELECT * FROM opportunities WHERE id = 4158460;\nSELECT * FROM crm_field_data WHERE object_id = 4158460;\n\nselect * from users where team_id = 1;\nSELECT * FROM users WHERE id = 7160;\nselect * from role_user where user_id = 3248;\n\nselect * from crm_field_values where crm_field_id = 66824;\n\nSELECT * FROM users WHERE id = 23470;\nselect * from crm_configurations;\n\n# ******************************************\nselect * from teams where id = 1038; # 23521, 966\nselect * from users where team_id = 1038;\nselect * from crm_configurations where id = 966;\nSELECT * FROM activities WHERE uuid_to_bin('e8dc7c34-31f6-4430-bd81-2d9d8f00dd07') = uuid; # 54965762\nSELECT * FROM activities WHERE uuid_to_bin('4b5727c0-69d1-428f-b8d9-b649055166e2') = uuid; # 54965764\nSELECT * FROM participants WHERE activity_id = 54965764;\n\nSELECT id, uuid, title, recording_state FROM activities WHERE uuid_to_bin('355f105d-5606-4379-b0c5-d91daf59f4ce') = uuid; # 54964075\nSELECT * FROM participants WHERE activity_id = 54964075;\nselect f.id, slug, title, tf.team_id from team_features tf JOIN features f on tf.feature_id = f.id\n where tf.team_id = 1038;\n\n\n# ****************************************** PD *************************\nselect * from teams where id = 1029;\nSELECT * FROM crm_configurations WHERE id = 957;\nSELECT * FROM activities WHERE uuid_to_bin('581a33cb-4343-44bd-ac0c-4a5cee71c5ec') = uuid; # 54963423\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('8b6bb2f7-e7a7-4aec-8806-fbfea7093ccd') = uuid; # 54963421\n\n# ****************************************** Close *************************\nselect * from teams where id = 1031;\nSELECT * FROM crm_configurations WHERE id = 959;\nSELECT * FROM activities WHERE uuid_to_bin('59c517e0-96c9-4dad-bfeb-b7bef72f8725') = uuid; # 54963517\n\n\nSELECT * FROM activities WHERE uuid_to_bin('07238011-25fa-418e-838b-fb21e82b9ea2') = uuid; # 54963433\n\nSELECT * FROM activities WHERE uuid_to_bin('d68311e3-ac34-49bd-bf04-88323a7e5352') = uuid; # 54966009\n\n# ****************************************** SF *************************\nSELECT * FROM activities WHERE uuid_to_bin('2bd4cfa3-4eb5-4c77-84a7-9ed46a21c988') = uuid;\n\nselect * from roles;\nselect * from permissions;\nselect * from permission_role where permission_id = 136;\n\nselect * from migrations order by id desc;\n\nselect * from teams where id IN (1, 1037);\nselect * from crm_layouts where crm_configuration_id = 1;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 1493;\nSELECT * FROM crm_fields WHERE id IN (1652,1661,66799,66814,66821,66836,66843,66846,66864,84752,182306,323580,380378);\n\nselect * from features;\nselect * from team_features where feature_id = 33;\nselect * from opportunities;\n\nselect * from teams;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1052 and sa.provider = 'hubspot';\n\nSELECT * FROM accounts where id = 11512582;\nselect * from activities where account_id = 11512582;","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.025,"top":0.06666667,"width":0.050694443,"height":0.034444444},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"app ~/jiminny/app","depth":6,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".circleci","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".cursor","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".github","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".sonarlint","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".vscode","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":".windsurf","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"app, sources root","depth":7,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Actions","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Component","depth":8,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Acl","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActionItems","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Activity","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivityAnalytics","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"ActivitySearch","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiActivityType","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiAutomation","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AiCallScoring","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnything","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Dtos, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Events, folder","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskAnythingPromptService.php","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"HistoryService.php, class","depth":10,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AskJiminnyAi","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"AWS","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"BillingManagement","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Cache","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CoachingFeedback","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"Country","depth":9,"on_screen":false,"role_description":"text"},{"role":"AXStaticText","text":"CustomerApi","depth":9,"on_screen":false,"role_description":"text"}]...
|
3865544371963712397
|
-7980155751442543530
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
#12066 on JY-20725-handle Project: faVsco.js, menu
#12066 on JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
HandleHubspotRateLimitTest
Run 'HandleHubspotRateLimitTest'
Debug 'HandleHubspotRateLimitTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
5
119
3
9
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Pipedrive;
use Carbon\Carbon;
use Devio\Pipedrive\Exceptions\ItemNotFoundException;
use Devio\Pipedrive\Exceptions\PipedriveException;
use Exception;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\PipedriveInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Events\Users\SocialAccountDisconnected;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\SocialAccountTokenInvalidException;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
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\OpportunitySyncStrategyResolver;
use Jiminny\Utils\CurrencyFormatter;
use Log;
use UnexpectedValueException;
class Service extends BaseService implements
PipedriveInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
RemoteEntityManipulationInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
private const int NOTE_BODY_MAX_LENGTH = 3000000;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day
private const string CALL_HEADER = 'Jiminny Summary';
/**
* @var ClientInterface|Client
*/
protected $client;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(Client $client, private readonly ProspectPhotoPathService $prospectPhotoPathService)
{
parent::__construct();
$this->client = $client;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Pipedrive';
}
/**
* @inheritdoc
*/
public function setUser(User $user): void
{
try {
parent::setUser($user);
} catch (InvalidArgumentException $exception) {
// In Pipedrive's case the OAuth client isn't setup to catch the failure properly. We handle this manually.
if ($this->client->oauthAccount->state !== SocialAccount::STATE_FULL_REFRESH_REQUIRED &&
$exception->getMessage() === 'Required option not passed: "access_token"') {
// Something terrible happened to their token, they need to reconnect.
$this->client->oauthAccount->update(['state' => SocialAccount::STATE_FULL_REFRESH_REQUIRED]);
event(new SocialAccountDisconnected($this->client->oauthAccount));
}
throw new SocialAccountTokenInvalidException('Cannot refresh token. Full refresh required.');
}
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_PIPEDRIVE);
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
switch ($fieldType) {
case Field::TYPE_DATE:
$value = $internal ? Carbon::parse($fieldValue) : $fieldValue;
break;
default:
$value = $fieldValue;
}
return $value;
}
/**
* Gets the valid fields for a given Object via the property APIs.
*
* @param string $crmObject The name of the CRM object. i.e. Opportunity or Contact
*/
protected function getFields(string $crmObject): array
{
switch ($crmObject) {
case 'task':
$properties = $this->client->getInstance()->activityFields();
break;
case 'account':
$properties = $this->client->getInstance()->organizationFields();
break;
case 'contact':
$properties = $this->client->getInstance()->personFields();
break;
case 'opportunity':
$properties = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException();
}
$fields = [];
try {
$response = $properties->all();
if ($response->isSuccess() === false) {
throw new Exception($response->getContent(), $response->getStatusCode());
}
foreach ($response->getData() as $property) {
$fields[] = ['label' => $property->name, 'name' => $property->key];
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::taskFollowupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
return;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
$field->description = null;
$field->label = mb_strimwidth($crmField->name, 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($crmField->field_type);
$field->is_selectable = $crmField->active_flag;
$field->save();
}
}
}
/**
* @param string $fieldType How the data should be input
*/
private function convertFieldType(string $fieldType): string
{
$fieldType = strtolower($fieldType);
switch ($fieldType) {
case 'enum':
case 'stage':
case 'status':
return Field::TYPE_PICKLIST;
case 'varchar':
return Field::TYPE_TEXT;
case 'int':
case 'double':
return Field::TYPE_NUMBER;
case 'text':
return Field::TYPE_TEXTAREA;
case 'monetary':
return Field::TYPE_CURRENCY;
case Field::TYPE_DATE:
case Field::TYPE_TIME:
case Field::TYPE_PHONE:
return $fieldType;
default:
// This is not actually a supported field.
return Field::TYPE_UNSUPPORTED;
}
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
switch ($field->object_type) {
case Field::OBJECT_TASK:
$crmFields = $this->client->getInstance()->activityFields();
break;
case Field::OBJECT_ACCOUNT:
$crmFields = $this->client->getInstance()->organizationFields();
break;
case Field::OBJECT_CONTACT:
$crmFields = $this->client->getInstance()->personFields();
break;
case Field::OBJECT_OPPORTUNITY:
$crmFields = $this->client->getInstance()->dealFields();
break;
default:
throw new InvalidArgumentException('Invalid field type "' . $field->object_type . '" for field ' . $field->id . '.');
}
if ($field->crm_provider_id == 'stage_id') {
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$dealStages = $this->client->getInstance()->stages()->all(['pipeline_id' => $pipeline->id])->getData();
foreach ($dealStages as $i => $dealStage) {
$values = [
'value' => $dealStage->id,
'label' => $dealStage->name,
'sequence' => $i,
];
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $dealStage->id,
], $values);
}
}
return $fieldValues;
}
// Sadly we must traverse the entire list because they don't allow to get an individual field by key.
foreach ($crmFields->all()->getData() as $crmField) {
if ($crmField->key === $field->crm_provider_id) {
// Import all active values.
if (property_exists($crmField, 'options')) {
foreach ($crmField->options as $i => $option) {
$values[] = [
'value' => $option->id,
'label' => $option->label,
'sequence' => $i,
];
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
$field->values()->whereIn('value', $fieldsToPurge)->delete();
}
}
return $fieldValues;
}
/**
* @inheritdoc
*
* @throws SocialAccountTokenInvalidException
* @throws PipedriveException
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// We create a business process to contain the pipeline, and later store all stages against it.
$pipelines = $this->client->getInstance()->pipelines()->all()->getData();
foreach ($pipelines as $pipeline) {
$businessProcess = $this->importPipelineData($pipeline);
$stage = $this->importStagesForBusinessProcess($businessProcess, $missingStageName);
if ($missingStageName !== null && $stage?->crm_provider_id == $missingStageName) {
$missingStage = $stage;
}
}
} catch (PipedriveException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
// Pipedrive doesn't use leads.
return 0;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
// Pipedrive doesn't use leads.
return null;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdAccounts = $this->client->getInstance()->organizations()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdAccounts->getData() === null) {
return 0;
}
foreach ($pdAccounts->getData() as $pdAccount) {
// Only sync if previously imported.
if ($this->hasAccount($pdAccount->id)) {
$this->importAccount($pdAccount);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
try {
$hsAccount = $this->client->getInstance()->organizations()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the company due to an external error.', $e->getCode(), $e);
}
return $this->importAccount($hsAccount->getData());
}
/**
* @inheritdoc
*/
private function importAccount($crmData): Account
{
$countryCode = $crmData->country_code;
if ($countryCode === null && $crmData->address_country) {
$countryCode = $this->convertCountryNameToCode($crmData->address_country);
}
$name = $crmData->name;
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
/**
* Pipedrive API point is inconsistent. Sometimes it delivers an object, sometimes it delivers an integer ID.
* These are real samples from API results:
* { ... owner_id: 23696001, ...}
* OR
* {
* ...
* owner_id: {
* active_flag: false,
* id: 12972717,
* name: 'name here',
* value: 12972717
* }
* }
*/
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->id : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$data = [
'team_id' => $this->team->id,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($name, 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Account::class,
fileName: (string) $crmData->id,
avatarText: $name,
),
'country_code' => $countryCode,
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Account */
return $this->config->accounts()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
try {
foreach ($strategies as $syncStrategy) {
foreach ($syncStrategy->fetchOpportunities($parameters) as $pdOpportunity) {
$this->importOpportunity($pdOpportunity);
$syncCount++;
}
}
$this->logger->info('[Pipedrive] Sync Opportunities completed', [
'team' => $this->team->getId(),
'syncCount' => $syncCount,
]);
} catch (Exception $exception) {
$this->logger->info('[Pipedrive] Failed to sync Opportunities ', [
'team' => $this->team->getId(),
'params' => $parameters,
'strategy' => $strategy,
'reason' => $exception->getMessage(),
'syncedBeforeFailure' => $syncCount,
]);
}
if ($syncCount > 1000) {
$this->logger->info('[Pipedrive] Sync Opportunities - count warning ', [
'team' => $this->team->getId(),
'total' => $syncCount,
]);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY,
);
$parameters = ['crm_id' => $crmId];
try {
$generator = $strategy->fetchOpportunities($parameters);
$pdOpportunity = $generator->current();
} catch (ItemNotFoundException | PipedriveException $e) {
$this->logger->info('[Pipedrive] Failed to sync opportunity', [
'crm_id' => $crmId,
'reason' => $e->getMessage(),
]);
return null;
}
return $this->importOpportunity($pdOpportunity);
}
/**
* @inheritdoc
*/
private function importOpportunity($crmData): ?Opportunity
{
if ($crmData->deleted) {
return null;
}
if ($crmData->org_id) {
$orgId = is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config->accounts()->where('crm_provider_id', (string) $orgId)->first();
if ($account === null) {
$account = $this->syncAccount($orgId);
if ($account === null) {
// For some bizarre reason, the org_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Account could not be imported ' . $orgId);
return null;
}
}
} elseif ($crmData->person_id) {
// Try to get the organization from the person.
$personId = is_object($crmData->person_id) ? $crmData->person_id->value : $crmData->person_id;
$contact = $this->config->contacts()->where('crm_provider_id', (string) $personId)->first();
if ($contact === null || $contact->account_id === null) {
$contact = $this->syncContact($personId);
if ($contact === null) {
// For some bizarre reason, the person_id is not importable from Pipedrive- bail.
$this->logger->info('[Pipedrive] Contact could not be imported ' . $personId);
return null;
}
if ($contact->account_id === null) {
$this->logger->info('[Pipedrive] No account associated with contact ' . $contact->getId());
return null;
}
}
$account = $contact->account;
} else {
// If there is nothing to associate this with, don't import it.
return null;
}
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $crmData->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $crmData->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $crmData->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $crmData->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$ownerId = is_object($crmData->user_id) ? $crmData->user_id->value : $crmData->user_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
$closeDate = null;
if ($crmData->expected_close_date) {
$closeDate = Carbon::parse($crmData->expected_close_date)->format('Y-m-d');
}
$remotelyCreatedAt = Carbon::parse($crmData->add_time);
$data = [
'team_id' => $this->team?->getId(),
'account_id' => $account?->getId(),
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->title, 0, 128),
'value' => $crmData->value,
'currency_code' => CurrencyFormatter::formatCode($crmData->currency),
'close_date' => $closeDate,
'is_closed' => $crmData->close_time !== null,
'is_won' => $crmData->won_time !== null,
'stage_id' => $stage?->getId(),
'record_type_id' => $recordType?->getId(),
'remotely_created_at' => $remotelyCreatedAt,
];
/** @var Opportunity $opportunity */
$opportunity = $this->config->opportunities()->updateOrCreate(['crm_provider_id' => (string) $crmData->id], $data);
// import external fields into crm_field_data if present
$crmFields = $this->getOpportunitySyncableFields();
$this->importOpportunityCrmFieldData((array) $crmData, $crmFields, $opportunity->getId());
if ($opportunity->wasRecentlyCreated) {
MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());
}
return $opportunity;
}
/**
* @inheritdoc
*/
public function syncContacts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
try {
$pdContacts = $this->client->getInstance()->persons()->all([
'limit' => 500,
'sort' => 'update_time DESC',
]);
if ($pdContacts->getData() === null) {
return 0;
}
foreach ($pdContacts->getData() as $pdContact) {
// Only sync if previously imported.
if ($this->hasContact($pdContact->id)) {
$this->importContact($pdContact);
$syncCount++;
}
}
} catch (Exception $exception) {
// Do nothing for now.
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncContact(string $crmId): ?Contact
{
try {
$pdContact = $this->client->getInstance()->persons()->find($crmId);
} catch (ItemNotFoundException | PipedriveException $e) {
throw new CrmException('Could not sync the contact due to an external error.', $e->getCode(), $e);
}
return $this->importContact($pdContact->getData());
}
private function importContact($crmData): Contact
{
$photoPath = null;
$account = null;
if ($crmData->org_id) {
/**
* Pipedrive API is inconsistent, it may return a string variable, or an object
*/
$crmProviderId = (string) is_object($crmData->org_id) ? $crmData->org_id->value : $crmData->org_id;
$account = $this->config
->accounts()
->where('crm_provider_id', $crmProviderId)
->first();
if ($account === null) {
$account = $this->syncAccount($crmProviderId);
$photoPath = $this->teamService->generateAvatar(
$crmData->id,
$crmData->name,
);
} else {
$photoPath = $account->photo_path;
}
}
if ($photoPath === null) {
$photoPath = $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: (string) $crmData->id,
modelType: Contact::class,
fileName: (string) $crmData->id,
avatarText: $crmData->name,
);
}
$ownerId = $profile = null;
if ($crmData->owner_id) {
$ownerId = is_object($crmData->owner_id) ? $crmData->owner_id->value : $crmData->owner_id;
$profile = $this->config->profiles()->where('crm_provider_id', (string) $ownerId)->first();
}
$parsedNumber = $this->buildContactPhone(null, $crmData->phone[0]->value);
$mobileNumber = null;
if (isset($crmData->phone[1])) {
$mobileNumber = $this->buildContactMobilePhone(null, $crmData->phone[1]->value);
}
$email = null;
if (isset($crmData->email[0]->value)) {
$email = mb_strimwidth($crmData->email[0]->value, 0, 191);
}
$data = [
'team_id' => $this->team->id,
'account_id' => $account->id ?? null,
'user_id' => $profile ? $profile->user_id : null,
'owner_id' => $ownerId,
'name' => mb_strimwidth($crmData->name ?? '', 0, 100),
'email' => $email,
'phone' => $parsedNumber['phone'] ?? null,
'mobile_phone' => $mobileNumber ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'title' => $crmData->job_title ?? null,
'photo_path' => $photoPath,
'remotely_created_at' => Carbon::parse($crmData->add_time),
];
/** @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;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
// Not applicable.
}
/**
* @inheritdoc
*/
public function syncProfiles(?User $userToSearch = null): ?\Jiminny\Models\Crm\Profile
{
// Not needed yet.
return null;
}
protected function getFieldTypes(): array
{
return [
parent::OBJECT_TASK,
parent::OBJECT_ACCOUNT,
parent::OBJECT_CONTACT,
parent::OBJECT_OPPORTUNITY,
];
}
/**
* @inheritdoc
*/
public function syncProfileFields(): void
{
$fieldTypes = [
'task',
'account',
'contact',
'opportunity',
];
foreach ($fieldTypes as $fieldType) {
try {
$currentFields = $this->getFields(ucfirst($fieldType));
$newFields = array_column($currentFields, 'name');
$changedFields = array_merge(
array_diff($this->profile->getFieldsAsArray($fieldType), $newFields),
array_diff($newFields, $this->profile->getFieldsAsArray($fieldType)),
);
if (\count($changedFields) > 0) {
$fields = implode(',', $newFields);
Log::debug(ucfirst($fieldType) . ' fields changed from ' . $this->profile->{$fieldType . '_fields'} . ' to: ' . $fields);
/*
// If they have changed, raise a notification to the owner.
$team->owner->notify(
new SyncedFieldsChanged($fieldType, $profile->getFieldsAsArray($fieldType), $newFields)
);
*/
$this->profile->{$fieldType . '_fields'} = $fields;
}
} catch (Exception $exception) {
Log::error('No access to ' . $fieldType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
$this->profile->save();
}
/**
* @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 = [];
$params = [
'term' => $name,
'limit' => $count,
'start' => $offset,
'item_types' => 'person,organization',
];
$objects = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$objects = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
if ($exception->getMessage() === 'Invalid token: access token is invalid') {
throw new SocialAccountTokenInvalidException($exception->getMessage());
}
throw $exception;
}
if (empty($objects['items'])) {
return [];
}
// Build mapped list.
foreach ($objects['items'] as $object) {
$object = $object['item'];
// We only support these object types today.
if (\in_array($object['type'], ['person', 'organization']) === false) {
continue;
}
$objectType = $object['type'] === 'person' ? 'contact' : 'account';
$record = [
'crmId' => $object['id'],
'crmUrl' => $this->generateProviderUrl($object['id'], $objectType),
'name' => $object['name'],
'industry' => null,
'title' => null,
'prospectType' => $objectType,
'phoneNumbers' => [],
];
if (isset($object['organization']['name'])) {
$record['organization'] = $object['organization']['name'];
}
if (isset($object['phones'][0])) {
$parsedNumber = $this->buildContactPhone(null, $object['phones'][0]);
// 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 = [
'only_primary_association' => 1,
'status' => 'open',
'sort' => 'update_time DESC',
];
switch ($this->config->opportunity_assignment_rule) {
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_RECENTLY_CREATED:
$params['sort'] = 'add_time DESC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_OPEN_OLDEST_CREATED:
$params['sort'] = 'add_time ASC';
break;
case Configuration::OPP_ASSIGNMENT_ALL_RECENTLY_UPDATED:
$params['sort'] = 'update_time DESC';
$params['status'] = 'all_not_deleted';
}
$pdOpportunities = $this->client->getInstance()->organizations()->deals($account->crm_provider_id, $params)->getData();
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
if ($pdOpportunities === null) {
return [];
}
foreach ($pdOpportunities as $pdOpportunity) {
// Stage and RecordType are part of BusinessProcess
$businessProcess = $this->getBusinessProcesses((string) $pdOpportunity->pipeline_id);
if ($businessProcess === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
}
$stage = $this->getStage($businessProcess, (string) $pdOpportunity->stage_id);
if ($businessProcess !== null && $stage === null) {
$stage = $this->importStagesForBusinessProcess($businessProcess, (string) $pdOpportunity->stage_id);
}
/** @var ?RecordType $recordType */
$recordType = $businessProcess?->recordTypes()->first();
if ($businessProcess !== null && $recordType === null) {
$businessProcess = $this->importPipeline((int) $pdOpportunity->pipeline_id);
$recordType = $businessProcess?->recordTypes()->first();
}
$record = [
'crmId' => $pdOpportunity->id,
'name' => $pdOpportunity->title,
'value' => $pdOpportunity->formatted_value,
'won' => $pdOpportunity->won_time !== null,
'closed' => $pdOpportunity->status !== 'open',
'stage' => [
'id' => $stage?->id_string ?? '',
'name' => $stage?->name ?? '',
],
'recordType' => [
'id' => $recordType?->id_string ?? '',
'name' => $recordType?->name ?? '',
],
];
if ($ownerId && isset($pdOpportunity->user_id->id) && $pdOpportunity->user_id->id === (int) $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
$pdActivities = null;
$filters = ['done' => 0];
switch ($objectType) {
case 'contact':
$pdActivities = $this->client->getInstance()->persons()->activities($objectId, $filters)->getData();
break;
case 'account':
// Prioritize looking up activities on the deal (which should be linked on the account anyway).
if ($opportunityId) {
$pdActivities = $this->client->getInstance()->deals()->activities($opportunityId, $filters)->getData();
}
// If no deal activity, fall back to the organization.
if ($pdActivities === null) {
$pdActivities = $this->client->getInstance()->organizations()->activities($objectId, $filters)->getData();
}
break;
}
if ($pdActivities === null) {
return [];
}
foreach ($pdActivities as $pdActivity) {
// Only import active, scheduled tasks in the future.
if ($pdActivity->active_flag) {
$due = $pdActivity->due_date . ($pdActivity->due_time ? ' ' . $pdActivity->due_time . ':00' : '');
$data[] = [
'crmId' => $pdActivity->id,
'subject' => $pdActivity->subject,
'due' => Carbon::parse($due)->toIso8601String(),
'type' => $pdActivity->type,
];
}
}
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
{
$contact = null;
$account = null;
$params = [
'term' => $email,
'item_types' => 'person',
'fields' => 'email',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $exception) {
$this->logger->info('[Pipedrive] Email match failed', [
'email' => $email,
'reason' => $exception->getMessage(),
]);
return null;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if (
! $contact instanceof Contact
&& ! $account instanceof Account
) {
return null;
}
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (count($pdOpportunities) > 0) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
/**
* @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) {
return null;
}
$params = [
'term' => $phone,
'item_types' => 'person,organization',
'fields' => 'phone',
];
$searchResult = [];
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$searchResult = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Phone match failed', [
'phone' => $phone,
'reason' => $e->getMessage(),
]);
return null;
}
if (empty($searchResult['items']) || count($searchResult['items']) !== 1) {
return null;
}
$contact = $this->syncContact($searchResult['items'][0]['item']['id']);
$account = $contact->account;
$countryCode = $this->getCountryCode($contact, $account);
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function getCountryCode($contact, $account): ?string
{
if ($contact && $contact->country_code) {
return $contact->country_code;
} elseif ($account && $account->country_code) {
return $account->country_code;
}
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;
}
$result = Cache::remember($this->profile->id . $name, 60, function () use ($name, $userId) {
$contact = null;
$account = null;
$params = [
'term' => $name,
'item_types' => 'person',
'fields' => 'name',
];
$pdContact = null;
try {
if (! $this->client instanceof Client) {
throw new \InvalidArgumentException('Expected Pipedrive Client instance');
}
$response = $this->client->searchItems($params);
$pdContact = $response['data'] ?? null;
} catch (PipedriveApiException $e) {
$this->logger->info('[Pipedrive] Name match failed', [
'name' => $name,
'reason' => $e->getMessage(),
]);
return false;
}
if (! empty($pdContact['items']) && count($pdContact['items']) === 1) {
$contact = $this->syncContact($pdContact['items'][0]['item']['id']);
$account = $contact->account;
}
if ($contact || $account) {
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$pdOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId,
);
} catch (Exception $e) {
$pdOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($pdOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($pdOpportunities[0]['crmId']);
$stage = $opportunity?->stage;
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
return false;
});
return is_array($result) ? $result : null;
}
/**
* @inheritdoc
*/
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);
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45283
|
1623
|
24
|
2026-05-14T14:29:01.717170+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768941717_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.054166667,"top":0.027777778,"width":0.08055556,"height":0.035555556},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8043719072324535154
|
-8628527368849355612
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
PhpStormFileEditViewNavig Project: faVsco.js, menu
PhpStormFileEditViewNavigateCodeLaravelRefactorRunToolsGitWindowHelp•FV faVsco.js* #12066 on JY-20725-handle-HS-search-rate-limit k vProject v© ReportController.php© AutomatedReportGenerated.phpablHandleHubspotRateLimitTest100% C8• Thu 14 May 17:29:01* :QTrackAutomatedReportGeneratedEvent.phpPlaybackController.php:=custom.log© DecorateActivity.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.phpSendReportJob.php© LocalSearch.php• LocalSearchlnterface.php• DeleteCrmEntityTrait.phpDeleteAccountJob.php© ImportActivityTypes.phpT WriteCrmTrait.php© DecorateActivity.php© RemoteSearch.php© Salesforce/Service.php© LogActivityTrait.phpPipedrive/Service.phpPlainTextDecorateActivity.php© Service.php18v C Listeners© ConvertLeadActivities.php© ActivityPlaybookTrait.phpE.envCrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.stagingDetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.phpClient.php© PurgeLookupCache.php› D Metadata50> C MigrationA5 A 119 X3 X9 ^1511v Pipedrive1515© HubspotPaginationService.php© HandleHubspotRateLimit.phpclass Service extends BaseServiceimplementsprivate function buildCallPayload(Activity $activity): Activitythrow new Exception( message: 'No mapped CRM type in Pipedrive for "*• Sactivity->category->name> D OpportunitySyncStrategy> D ProspectSearchStrategy15161517© ApiFields.php1518© Client.php1519© FieldDefinitions.php© PipedriveApiClient.php15201521© PipedriveApiException.php© Service.php15221523© TokenStorage.phpv D Salesforce15241525> C Fields> OpportunityMatcher15261527> C OpportunitySyncStrategy> D ProspectSearchStrategy1528$data = ['done' => 1,'user_id' => $this->profile->crm_provider_id,'due_time' => $activity->scheduled_start_time->format( format: 'H:i'),'due_date' => Sactivity->scheduled_start_time->toDateString(),'duration' => gmdate( format: 'H:i', $activity->duration),'type' => $type->value,'subject' => Sthis->generateActivityTitle(Sactivity),'note' => $this->generateActiv] + $this->fetchCustomFieldData($a+ $this->convertActivityAssociat1529C ServiceTraits© Serviceprivate function generateActivityTitle(Activity $activity): string1530return Sthis->upsertActivity(SactiT BatchSyncTrait.php1531T FollowupActivityTrait.phpParameters: \ActivitySactivityD LogActivityTrait.php15321 usageSource:T RecordManipulationsTrai1533private function buildTextMessagePaylo.../app/Services/Crm/Pipedrive/Service.phpT SyncFieldsTrait.php1552© Client.php2 usages© DecorateActivity.phpC DeleteObjectsTrait.php15531576private function upsertActivity(Activity Sactivity, array $data): Activity{...© FieldDefinitions.php1 usage© PavinadRuilder nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)W Windsurf Teams=laravel.logSF [jiminny@localhost]A HS_local [jiminny@localho:W4 console [QAI PROD] X4 console [PROD]A console (EU]D211Go jiminny0841A3 X4 A Vm migrations oi212=213=214215=216=m teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wi217: 218219220m features;m team_feature:m opportunitie:III221.222m teams;— 223|= 224|1.id, CASEWHEN-225-226=227—- 228229=230idFROMsocial,1 on u.id= sa.:1..n<->1: on t.Lid = 1052 and=231— 232=233 vIMaccounts wheiactivities=2341511:22UTF-8Co 4 spaces...
|
45281
|
NULL
|
NULL
|
NULL
|
|
45282
|
1624
|
15
|
2026-05-14T14:28:46.637506+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768926637_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeavev Q SearchDate ModifiedYesterday at 13.34Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:32resterday al i5.sYesterdav at 13:31Yesterday at 13:30Yesterday at 13.29Yesterdav at 13:29resterday at 15.2oYesterday at 13:27Yesterday at 13:26Yesterday at 13:26Yesterdav at 13:25Yesterday at 13.24Yesterdav at 13:24Yesterday at 13:23Yesterday at 13:21Voctorday at 12:21Yesterday at 13.20Yesterdav at 13:19Yesterdav at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13:17Yesterday at 13.16Yecterdav at 12:16Yesterdav at 13:15resterday at 13-10Yesterdav at 13:14Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterday at 13.11Yesterdav at 13:11Yesterday at 13:10Yesterday at 13:09Yoctorday at 12:00Yesterdav at 13:08Yesterday at 13.08Yecterdav at 12:07Yesterday at 13:07Yesterdav at 13:0617K:MPEG-4 movie29 KB6 KB12 Kb9 KBI7 KB8 KB37 K:MDEG-A movieMPEG-4 movieMPEG-4 movieMPEG-4 movie10 KB7 KBMDEG-A movieMPEG-4 movie9 KBI8 KB72 KB14 K:MPEG-4 movieMPEG-4 movie13 KBMDECA mavile9 KB18 KB|MPEG-4 movie12 KBI10 KB16 KBMPEG-1 movidMPEG-4 movie6KEMPEG-4 movie6 KB MPEG-4 movie12 KB23 K:MPEG-4 movie8 KB6 KBMDSG.A movicMPEG-4 movie11 KE11 KBMPEG-4 movieMPEG-4 movie20 KB34 K:MPEG-4 movie10kр7 KB5KBMDEG.A movidMPEG-4 movie11 KPMDEG-A movie26 KB MPEG-4 movie111 KBMPEG-4 movie102 K:88 KB59 KB98 KBMPEG-4 movieMDEC.A moviaMPEG-4 movie07 KPMDEG-A movie66 KBMPEG-4 movie44 KBMPEG-4 movie03 K:IMPEG-A movid78 KB MPEG-4 movie50 KB58 KB27 KPMPEG-4 movieMDEG.A movio7 KB12 KBMPEG-4 movie22 K:17 KBMDEC A movid19 KB32 K8MPEG-4 movieFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfP Kdf-a,k Ffmiv Tree.sebitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esviconfig.vmlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•Alfredv P1 mazanoko imaaoc. VWI6ана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.54 GB availabld• Inu 14 Mау 1/.20.40Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-4350145192211111468
|
NULL
|
click
|
ocr
|
NULL
|
Jiminny... ~ActivityFilesLater@ jiminny-x-integrat Jiminny... ~ActivityFilesLater@ jiminny-x-integrati• plattorm-inner-teamE Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity lab# engineering# general#jiminny-bgac nlattorm-nckets# product launches# random# releases# sofia-officed sunport# thank-yous# the people of iimi... Direct messages• 8la 62% Galya Dimitrovavasil VasilevM Stefka StovanovaSg: Todor StamatovMario GeorgievNiudlay lanay. James Graham "* Stoyan Tanevo Stelivan Georgiev( Petko Kashinski*. Lukas Kovali...::: AnnsToastS lira Gloud6d Huddle with Aneliva Angelova* Aneliya Angelova &• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някъде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preuiew in sact[HubSpotl Optimise CRM rematct(7 OpenRosdy for OA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira* SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle (LIVE 4:06 PMAneliva Angelova is here toolAneliva Angelova 4:44 PN11512582Lukas Kovalik M 5.16 PNcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-vne' annlication/ison'--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIr7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeavev Q SearchDate ModifiedYesterday at 13.34Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:32resterday al i5.sYesterdav at 13:31Yesterday at 13:30Yesterday at 13.29Yesterdav at 13:29resterday at 15.2oYesterday at 13:27Yesterday at 13:26Yesterday at 13:26Yesterdav at 13:25Yesterday at 13.24Yesterdav at 13:24Yesterday at 13:23Yesterday at 13:21Voctorday at 12:21Yesterday at 13.20Yesterdav at 13:19Yesterdav at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13:17Yesterday at 13.16Yecterdav at 12:16Yesterdav at 13:15resterday at 13-10Yesterdav at 13:14Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterday at 13.11Yesterdav at 13:11Yesterday at 13:10Yesterday at 13:09Yoctorday at 12:00Yesterdav at 13:08Yesterday at 13.08Yecterdav at 12:07Yesterday at 13:07Yesterdav at 13:0617K:MPEG-4 movie29 KB6 KB12 Kb9 KBI7 KB8 KB37 K:MDEG-A movieMPEG-4 movieMPEG-4 movieMPEG-4 movie10 KB7 KBMDEG-A movieMPEG-4 movie9 KBI8 KB72 KB14 K:MPEG-4 movieMPEG-4 movie13 KBMDECA mavile9 KB18 KB|MPEG-4 movie12 KBI10 KB16 KBMPEG-1 movidMPEG-4 movie6KEMPEG-4 movie6 KB MPEG-4 movie12 KB23 K:MPEG-4 movie8 KB6 KBMDSG.A movicMPEG-4 movie11 KE11 KBMPEG-4 movieMPEG-4 movie20 KB34 K:MPEG-4 movie10kр7 KB5KBMDEG.A movidMPEG-4 movie11 KPMDEG-A movie26 KB MPEG-4 movie111 KBMPEG-4 movie102 K:88 KB59 KB98 KBMPEG-4 movieMDEC.A moviaMPEG-4 movie07 KPMDEG-A movie66 KBMPEG-4 movie44 KBMPEG-4 movie03 K:IMPEG-A movid78 KB MPEG-4 movie50 KB58 KB27 KPMPEG-4 movieMDEG.A movio7 KB12 KBMPEG-4 movie22 K:17 KBMDEC A movid19 KB32 K8MPEG-4 movieFavourites• jiminny• Recents* ApplicationsiCloud• iCloud Drive992 Svnc tolde0 DXP4800PLUS-B5F49 Networl• CRMI• Orange• Red|• Yellow• Green• Purple•) All lags..IhlDownloadsNameLoom.pkgAlfred copv.alfredoreterences=KeychronAssist-1.0.2 (1).dmeKeychronAssist-1.0.2.dmgmazanoke-images-yWJo.zioPhotos-3-001.zip• Transcript.pd→mage U.loge1 Orioninstaller.dma_image.jpg- image (2).1pc• ПO-22221726037035-004-001_ORGES.pdf%D0%9F%D0%9E-22221726037035-004-001 archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterencesСE 060209С О000000026571172 CWICT 0Р70501260015900 ndt= 27022026_0000000026574472_ SWIFT_ [IBAN].pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001reportxmmi=pdt.odiB pdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfP Kdf-a,k Ffmiv Tree.sebitwarden export 20251031122528.isonlKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst aid_notes_complete.docxrenortl2).esviconfig.vmlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.isonnokc.901a6502.6667.A62h-062 ccu•Alfredv P1 mazanoko imaaoc. VWI6ана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик iро• Dhotac 2.001Q SearchKind00,4 MD55.9 MBinstdlle..dckageAlfred...ferences10,1 MB10,1 MBIL MBDisk ImageDisk ImageLiP archive6.6 MBZIp archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDE Document140 KbZIP archive148 KВ148 KB122 KBZIP archivelZIP archiveXML document111 KBAlfred...ferences94 KBDDE Documont92 KBPDr DocumentK:PDF Document91 KB91 KBXML document30 KBn0kpDDE Nocumont28 KBPDF Document28 KRPDE Document28 KB27 KBDocument14 KB11 KB6 KB6KBJSONINCV NacumontZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBVAMI dosumon1 KB029 buteccSV Documenthttlicence183 bytesZero butesJSONAlfrod foronso!Zero bytesFolde1.9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.54 GB availabld• Inu 14 Mау 1/.20.40Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 ,Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3712 Goh 2006 9t 11:5 A1Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nau 2025 4+ 17:506 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0419 Mar 2026 at 11:55|10 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:362616 Oct 2025 aт 16:0122 Aar 2026 at 12:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
45280
|
1624
|
14
|
2026-05-14T14:28:41.923061+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768921923_m2.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterday at 13.34Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:32resterday al i5.sYesterdav at 13:31Yesterday at 13:30Yesterday at 13.29Yesterday at 13:29resterday at 15.2oYesterday at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:25Yesterday at 13.24Yesterdav at 13:24Yesterday at 13:23Yesterday at 13:21Yesterday at 13:21Yesterday at 13.20Yesterdav at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13:17Yesterday at 13.16Yecterdav at 12:16Yesterday at 13:15Yesterday at 13.10Yesterdav at 13:14Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterday at 13.11Yesterdav at 13:11Yesterday at 13:10Yesterday at 13:09Yesterday at 13:09Yesterday at 13:08Yesterday at 13.08Yecterdav at 12:07Yesterday at 13:07Yesterdav at 13:0617K:MPEG-4 movie29 KBMPEG-4 movie6 KB12 Kb9 KBIMPEG-4 movieMPEG-4 movie7 KB8 KBMPEG-4 movie37 K:10 KBMPEG-4 movie7 KBMPEG-4 movie9 KBMPEG-4 movie8 KB72 KBMPEG-4 movie14 K:13 KBMPEG-4 movie9 KB18 KB|MPEG-4 movie12 KBMPEG-4 movie10 KB16 KBMPEG-4 movie6KEMPEG-4 movie6 KB MPEG-4 movie12 KB23 K:MPEG-4 movie8 KBMPEG-4 movie6 KBMPEG-4 movie11 KEMPEG-4 movie11 KBMPEG-4 movie20 KB34 K:MPEG-4 movie10 KBMPEG-4 movie7 KB5KBMPEG-4 movie11 KPMPEG-4 movie26 KB MPEG-4 movie111 KBMPEG-4 movie102 K:MPEG-4 movie88 KBMPEG-4 movie59 KB98 KBMPEG-4 movie07 KPMDEG-A movie66 KBMPEG-4 movie44 KBMPEG-4 movie03 K:IMPEG-A movid78 KB MPEG-4 movie50 KB58 KB27 KPMPEG-4 movieMPEG-4 movie7 KB12 KBMPEG-4 movie22 K:17 KBMPEG-4 movie19 KB32 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-ywJo.ziPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfB Rovaix Famly Treo gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.54 GB availabld• Inu 14 Mау 1/.20.47Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
NULL
|
-2597347445217722326
|
NULL
|
click
|
ocr
|
NULL
|
ActivityFilesLaterJiminny... ~@ jiminny-x-integrat ActivityFilesLaterJiminny... ~@ jiminny-x-integrati& platform-inner-team© Channels# ai-chapter# alertsi backend# bugscontusion-cllnia# curiosity_lab# engineering# general# jiminny-bg# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the people of iimi..• Direct messages8. A...• A3 62P. Galya Dimitrova Mvasil VasilevA. Stefka Stoyanova%: Todor Stamatovf. Mario GeorgievP. Nikolay Ivanov2o James Graham "2. Stoyan Tanev. Steliyan Georgiev. Petko Kashinski*. Lukas Kovali...a: Apps® ToastS lira Gloud6 Huddle with Aneliya Angelova& R. Aneliya Angelova •• Messagest Add canvasur FilesAneliya Angelova 2:30 PMЛукаш, само при Сейлсфорс и Хубспот се синкваха активити типовете, когато се направи плейбук,нали?пои останалите скМі тоябва оъчно ла се въвелат.Lukas Kovalik 2:47 PMтряова да се за всички, някьде не се ли попьлвапри зохо май беше hardcoded но май и там си връщаха две категорииAneliya Angelova @ 4:00 PMЛукаш имаш ли време да се чуем за тестването на https://jiminny.atlassian.net/browse/JY-20725X Preview insackt[HubSpot] Optimise CRM rematctC OpenReady for QA- MediumAA Aneliya AngelovaAs of today at 4:00 PM RetreshOpen in Jira+ SummariseLukas Kovalik 4:02 PMzean-iurwiniareelYou joined the huddle LIVE 4:06 PMAneliva Angelova is here toolAneliya Angelova 4:44 PM11512582Lukas Kovalik O 5:16 PMcurl --location "httns:ani.hubani.com/crm/v3/obiects/contacts/search--header 'Content-Type: application/json'\--header "Authorization: BearerC|Kvma?iMx7OINOM8kOFwrAowAcAkUAhIR24?A05D?xiVrIYMILuP6SA01Kwcmhr=n.v2 hytorDniMvDalYck-CMalovolNOM9kOFwrAUAcrkcaws-ThwRARIRAOF-ATFSAOFRAO-ROIEBAQUBEggBAQEBAYICFIґ7Au8O2Nwal9YW2CbCCxYvwngmSgNldTFSAFoAYABoAHAAeAA'\--data "{ "limit", 13'Message Aneliva Angelova+ AaIAl Notes: OffLeave~ (Q SearchDate ModifiedYesterday at 13.34Yesterday at 13:34Yesterday at 13.33Yesterdav at 13:32resterday al i5.sYesterdav at 13:31Yesterday at 13:30Yesterday at 13.29Yesterday at 13:29resterday at 15.2oYesterday at 13:27Yesterday at 13:26Yesterday at 13:26Yesterday at 13:25Yesterday at 13.24Yesterdav at 13:24Yesterday at 13:23Yesterday at 13:21Yesterday at 13:21Yesterday at 13.20Yesterdav at 13:19Yesterday at 13:19Yesterday at 13:18Yesterday at 13:18Yesterday at 13:17Yesterday at 13.16Yecterdav at 12:16Yesterday at 13:15Yesterday at 13.10Yesterdav at 13:14Yesterday at 13:14Yesterday at 13:13Yecterdav at 12:12Yesterday at 13.11Yesterdav at 13:11Yesterday at 13:10Yesterday at 13:09Yesterday at 13:09Yesterday at 13:08Yesterday at 13.08Yecterdav at 12:07Yesterday at 13:07Yesterdav at 13:0617K:MPEG-4 movie29 KBMPEG-4 movie6 KB12 Kb9 KBIMPEG-4 movieMPEG-4 movie7 KB8 KBMPEG-4 movie37 K:10 KBMPEG-4 movie7 KBMPEG-4 movie9 KBMPEG-4 movie8 KB72 KBMPEG-4 movie14 K:13 KBMPEG-4 movie9 KB18 KB|MPEG-4 movie12 KBMPEG-4 movie10 KB16 KBMPEG-4 movie6KEMPEG-4 movie6 KB MPEG-4 movie12 KB23 K:MPEG-4 movie8 KBMPEG-4 movie6 KBMPEG-4 movie11 KEMPEG-4 movie11 KBMPEG-4 movie20 KB34 K:MPEG-4 movie10 KBMPEG-4 movie7 KB5KBMPEG-4 movie11 KPMPEG-4 movie26 KB MPEG-4 movie111 KBMPEG-4 movie102 K:MPEG-4 movie88 KBMPEG-4 movie59 KB98 KBMPEG-4 movie07 KPMDEG-A movie66 KBMPEG-4 movie44 KBMPEG-4 movie03 K:IMPEG-A movid78 KB MPEG-4 movie50 KB58 KB27 KPMPEG-4 movieMPEG-4 movie7 KB12 KBMPEG-4 movie22 K:17 KBMPEG-4 movie19 KB32 K8MPEG-4 movieFavouritesE jiminny© Recents* ApplicationsiCloudiCloud Drive228 Sync folderQ DXP4800PLUS-B5FA@ Network|• CRMI• Orange• Red• Yellow• Green• Purple•) All lags..lohlDownloadsNameLoom.pkgAlfred copv.alfredoreterencesB KeychronAssist-1.0.2 (1).dmgA Keychron Assist-1.0.2.dmgmazanoke-images-ywJo.ziPhotos-3-001.zipD Transcript.pdf→mage U.loge1 Orioninstaller.dmaimage.jpg- image (2).1pc• ПО-22221726037035-004-001_ORGES.pdf• %D0%9F%D0%9E-22221726037035-004-001_archive.zipПO-22221726037035-004-001_archive (1).zip• repon 4).XmAltred copy2.altredoreterences05012026_0000000026574472_ SWIFT_OB70501260015890.pdf27022026_0000000026574472_SWIFT_OB72702260049200.pdf= 03042026 [CREDIT_CARD] SWIFT [CREDIT_CARD].001B reporti) xm=pdt.odipdf-1.pdfD pdf-2.pdf-pdf-5.pd1= ndf-1 ndipdf-3.pdfB Rovaix Famly Treo gedbitwarden export 20251031122528.isonKoválik Family Tree.zip*macOS Storage_Cleanup.mdal favicon icofirst_aid_notes_complete.docxrenortl2).esvconfig.ymlIteration run Search HS.postman_collection.json--report(1).csvm licence hettertouchtoalMariusHosting Config.json1ooks-891a6503-bbb7-4b2b-9c3.csv•Alfredmazanoke-images-YWJ6ана Ковалик.jpg•искане даниел Ковалик..pg• Фактура Март Даниел Ковалик.jрс• Фактура Април Даниел Ковалик.jpg• Dhotac 2.001Q SearchKind00,4 MDinstdlle..dckage55.9 MBAlfred...ferences10,1 MBDisk Image10,1 MBDisk ImageIL MBLiP archive6,6 MBZIP archive2,5 MB PDF Document2,5 MBJreo lmage2.2 MBDisk Image2,2 MB PDF Document2 MBJPEG image1,9 MBJPEG imaqe192 KBPDF Document140 KbZIP archive148 KВ148 KB122 KB111 KB94 KBZIP archivelZIP archiveXML documentAlfred...ferencesPDF Document92 KBPDr DocumentK:91 KB91 KB30 KB29 KBPDF DocumentXML documentPDF Document28 KBPDF Document28 KR28 KB27 KB14 KB11 KB6 KB6KBPDE DocumentDocumentJSONICSV DocumentZIP archiveMarkdo…..ument5KRWindo...n image4 KB3 KBword ..cumentCSV Document2 KBYAML document1 KBcSV Document928 byteshttlicence183 bytesZero butesJSONAlfred.. ferencesZero bytesFolde1,9 MB1,8 MB17 MB1,7 MBColdorJPEG imageJPEG ImageIPEG imadeJPEG imageColdo1 of 58 selected, 8.54 GB availabld• Inu 14 Mау 1/.20.47Date AddedIs Mdl ZUzo dl 19:4530 Jan 2026 at 12:3617 Mar 2026 at 20:2717 Mar 2026 at 20:26Z3 Aor 2020 al 13:0229 Jan 2026 at 15:2019 Dec 2025 at 10:1619 Dec 2025 at 12:238 Aor 2026 at 20:3519 Dec 2025 at 10:2919 Dec 2025 at 12:1819 Dec 2025 at 12:4026 Mar 2026 at 11:2410 May 2026 at 13:5326 Mar 2026 at 11:2426 Mar 2026 at 11:2426 Mar 2026 at 11:2310 May 2026 at 14:3730 Jan 2026 at 12:3713 Feb 2026 at 11:54Z3 Apr 2026 at 13.0823 Aor 2026 at 13:0810 May 2026 at 13:5410 May 2026 at 14:3710 May 2026 at 13:4910 May 2026 at 13:5010 May 2026 at 13:5110 Mav 2026 at 12:51110 May 2026 at 13:5019 Dec 2025 at 11:3431 Oct 2025 at 12:2525 Nov 2025 at 17:596 Mar 2026 at 11.2224 Anr 2026 at 16:5220 Oct 2025 at 11:0218 Mar 2026 at 15:29• Mav 2026 at 11:09129 Oct 2025 at 19:329 May 2026 at 10:0418 Mar 2026 at 11:5510 May 2026 at 13:5712 Jun 2025 at 19:0430 Jan 2026 at 12:3630 Jan 2026 at 12:3616 Oct 2025 aт 16:0123 Apr 2026 at 13:0223 Apr 2026 at 13:0223 Apr 2026 at 13:0222 Anr 2026 at 12:0223 Apr 2026 at 13:0229 Jan 2026 at 15:20...
|
45276
|
NULL
|
NULL
|
NULL
|
|
45278
|
1623
|
21
|
2026-05-14T14:28:41.171795+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-14/1778 /Users/lukas/.screenpipe/data/data/2026-05-14/1778768921171_m1.jpg...
|
PhpStorm
|
faVsco.js – Pipedrive/Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormFileEdit ViewNavigateCodeLaravelRefactorRu PhpStormFileEdit ViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% (<478• Thu 14 May 17:28:40Fv faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest* :QProject v+.|•х:-© ReportController.php© AutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© DecorateActivity.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.php© LocalSearch.php• LocalSearchlnterface.php• DeleteCrmEntityTrait.phpDeleteAccountJob.phpImportActivityTypes.phpT WriteCrmTrait.php© RemoteSearch.php© Salesforce/Service.php© LogActivityTrait.phpService.php XPlainTextDecorateActivity.php© Service.php© CrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18v D Listeners© ConvertLeadActivities.php© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.php© PurgeLookupCache.php› D Metadata© HubspotPaginationService.phpHandleHubspotRateLimit.php> Migrationv Pipedrive> D OpportunitySyncStrategy> ProspectSearchStrategy© ApiFields.php© Client.php© FieldDefinitions.php© PipedriveApiClient.php© PipedriveApiException.php© Service.php© TokenStorage.phpv D Salesforce> C Fields› OpportunityMatcher> C OpportunitySyncStrategy> D ProspectSearchStrategyv D ServiceTraitsT BatchSyncTrait.phpT FollowupActivityTrait.phpD LogActivityTrait.phpT RecordManipulationsTraiT SyncFieldsTrait.php© Client.php© DecorateActivity.php© DeleteObjectsTrait.php© FieldDefinitions.php@ PavinadRuilder nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=custom.logPlaybackController.phpSendReportJob.php=laravel.log© DecorateActivity.phpSF [jiminny@localhost]T ActivityPlaybookTrait.phpA HS_local [jiminny@localho:WE.env« console [QAI PROD) XClient.php4 console [PROD]A console (EU]D V234Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASE WHENidFROMsocial.1 on V.id = sa.:1..n<->1: on t.Lid = 1052 andIMaccounts wheiactivitiesW Windsurf Teams63:56UTF-8Co 4 spaces...
|
NULL
|
-6620716494202009342
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormFileEdit ViewNavigateCodeLaravelRefactorRu PhpStormFileEdit ViewNavigateCodeLaravelRefactorRunToolsGitWindowHelplhl100% (<478• Thu 14 May 17:28:40Fv faVsco.jsv* #12066 on JY-20725-handle-HS-search-rate-limit k vHandleHubspotRateLimitTest* :QProject v+.|•х:-© ReportController.php© AutomatedReportGenerated.phpTrackAutomatedReportGeneratedEvent.php© DecorateActivity.phpUserAutomatedReportsController.phpPlanhatService.phpAutomatedReportResult.php© LocalSearch.php• LocalSearchlnterface.php• DeleteCrmEntityTrait.phpDeleteAccountJob.phpImportActivityTypes.phpT WriteCrmTrait.php© RemoteSearch.php© Salesforce/Service.php© LogActivityTrait.phpService.php XPlainTextDecorateActivity.php© Service.php© CrmHelperRepository.php© AccountController.phpT IntegrationAppTrait.php= .env.staging18v D Listeners© ConvertLeadActivities.php© DetachActivityObject.phpRematchActivityOnCrmObjectDetach.phpMatchActivityCrmData.php© PurgeLookupCache.php› D Metadata© HubspotPaginationService.phpHandleHubspotRateLimit.php> Migrationv Pipedrive> D OpportunitySyncStrategy> ProspectSearchStrategy© ApiFields.php© Client.php© FieldDefinitions.php© PipedriveApiClient.php© PipedriveApiException.php© Service.php© TokenStorage.phpv D Salesforce> C Fields› OpportunityMatcher> C OpportunitySyncStrategy> D ProspectSearchStrategyv D ServiceTraitsT BatchSyncTrait.phpT FollowupActivityTrait.phpD LogActivityTrait.phpT RecordManipulationsTraiT SyncFieldsTrait.php© Client.php© DecorateActivity.php© DeleteObjectsTrait.php© FieldDefinitions.php@ PavinadRuilder nhnWorkspace associated with branch 'JY-20725-handle-HS-search-rate-limit' has been restored // Rollback // Configure... (today 16:17)=custom.logPlaybackController.phpSendReportJob.php=laravel.log© DecorateActivity.phpSF [jiminny@localhost]T ActivityPlaybookTrait.phpA HS_local [jiminny@localho:WE.env« console [QAI PROD) XClient.php4 console [PROD]A console (EU]D V234Go jiminny0841A3 X4 A Vm migrations oim teams wherem crm_layouts !iM crm_layout_eiIM crm_fields Wim features;m team_feature:m opportunitie:m teams;1.id, CASE WHENidFROMsocial.1 on V.id = sa.:1..n<->1: on t.Lid = 1052 andIMaccounts wheiactivitiesW Windsurf Teams63:56UTF-8Co 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|