|
88367
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomEV faVsco,ls ~ProjectvViewCooc#12121 on JY-20963-fix-InHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol Cimosewhoo© ResponseNormalize.phpCSeMCPonoexisiinastaad© SyncFieidAction.php10.02.23 Vasilev© synckelateoncuvnywanas 20.07.23 toni-yminny© WebhookSyncBatchProce20.07.23 toni-jiminny20.07.23 toni-fiminnylisteners20.07.23 toni-jiminny> Metadata20.07.23 toni-fiminnyMigration30.09.24 PapazovP oedrive20.07.23 toni-jiminnySalesforce19.03.18 Grahamaleelds19.03.18 GrahamOpportunityMatcher9.05.18OpportunitySyncStrategyGrahamProspectSearchStrategy19.03.18 GrahamUl taoro19.03.18 Graham© Cient.php2.04.18Olahan© DecorateActivity.php24.04.18 GrahamG DeleteObjectsTrait.phpewero oranat©FieldDefinitions.php© PayloadBuilder.php3.10.25© Profile.php3.10.25©QueryBullder.php©QueryHandler.php©Querylterator.php©QueryResults.php© Service.phpKHOS NIKOO10.09.25 ilian74.04.18 Graham24.04.18 Graham9oxh8 Graham© SyncBatchRedisService.ptTraits© BaseCllent.phpcrahem19.03.18 Graham© BaseService.php© CachedCrmServiceDecorator2.10.25© CrmActivityProviderintegrateCCWCMWMoronCermcontcurationsettinosse2.10.25©rmobiectsteso wer.ono17.03.25 ilian© DefaultProspectSearchStrate16.04.25 IvanovC Emal telcerond2.10.25Nikolov3sindeProscectinternce.ono2.04.18GrahamC) LavouMansoe ono® MatchDomainByEmailinterfac 2.04.18GrahamC Opportun tvActvitwlatcheee19.03.18 GrahamOpportun tySyneStratccvinte2.04.18Citanaiteennortur wewn.CtestomedProspectCache.php20.10.21 GrahamHe OrnenontCostrhSrond nhnentity // View pull request (today 16:12)WindowU ServiceTest ~© DeleteObjects Trait.phpuorcoaiuocrd.pntRecordSelector.php© Activity.php© Team,php© HubspotLastModifiedCreatedRecentlyOpenSyncStrategy.phpE custom.logE laravellogA HSJJocal (jiminny@localhost]A console (STAGING]# СОЛЬОС PHUA console (EU] x ( users (EU)Clost oias no seiveironx5 Cc334 C336337382384386 €389407409421413)415class Service extends BaseService inplements* Bcetucn EieldDatal]170601 47 A149 X1 233 21 A v 1707-17081709public function inportPicklistValues(17101711Eleld Sfield,1114array Soptions = [l'id''Label' »> "*, "value' »> **]1.): array {..)public function inportStages(Parray $types = null, Pstring SaissingStageNane = null): ?StageDnesoer nork// Use the HubSpot API client instead of the SDK crnPipelines() nethodsendooint= selt..oerten.sproeanesandoo.nroSpipelinesResponse = Sthis-›client->getInstance() ->getClient() -request( method:) "GET'.Soioeiaines a sotoeluneskesponse-›data-›resu.ts} catch (RequestException|BadRequest Sexception) (-1717-1718-1728=1720TITE{1726=1729=1730foreach (Spipelines as Spipeline) €NstaceseeE7sa-1733SELECT * FROM opportunities HHERE wwid_to_bin('04a9cfad-2c87-4453-sselect * fron teans where 1d= 555%select * fron stages where tean_id = 555;SETSTMT// We create a business process to contain the pipeline, and store all stages againstSp = ResponseNornalize::nermalizeP$peline(Spipeline);=1735CONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN ' (owner)' ELSE:1736-1737// Create/update business process for this pipelineSbusinessProcess = Sthis->config->businessProcesses()->update0rCreateCC-1738'ern_provider_id' => $p['id'],E17391. E1740sa.*,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idahittinnetneosonettsWHERE u.tean_id = 100 and sa.provider = 'hubsRes":=1742"tean_id'"name'=> Sthis->team->id,—1742select id, is_closed, is_won, stage_updated_at, crm_provider_id, st=> mb_strimwidth(Sp('Label'), (start: 0, (width: 150),=1743ype=> BusinessProcess:: TYPE_OPPORTUNITY,=1744close_date, forecast_category, deleted_at, created_at, renotely_crtfron opportunities where tean_id = 555 and stage_id = 20616 order]D):=> $p['active').=1745select id, crn_provider_id, nane, probability, updated_at, deletereI1 A record type is really a clone of the business process, used to store nhich record Vet 1748.fron stageswhere id = 28616;// Create/update record type cloneSthis->config->recordTypes()->update0rCreate(t'crn_provider_id' => Spl'id'],111Thc AutoORDER BY t.name, u.email;Se jiminny~031 A9 A29 V3 /109 A VSELECT * FROM teans WHERE nane LIKE "XTourlane%'; # 187, 209, 8154.SEEiNTCONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN ' (owner)' ELSE-Mrenasisa.*,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.nc->1: on t.id = u.tean_idWHERE u.tean_id = 187 and sa.provider = 'salesforce':select * fron activities nhere id = 31264367:select * fron contacts where id = 6331639;Belecekx tron accoun ancheo- 450054select * fron opportunities where id = 4843610;#Uodare0c0un8-4300-4"contact_id' = 6.#"stage_id' = 13273,"updated_at' = 2026-0S-22 07:16:select * fron text_relays where created_at > '2026-05-01':select * fron actviates order oy 1d desc;select * fron users where nane uike subras:Cascadeles orceeneeworeebee.ionHlomwstotino oosoTO0У L7Thu 28 May 19:59:53+0.There it is. The May 4 commit is JY-20917 | Update deleted stapes, restore in recrested . Let me see the full ditt:ait show tansidante e apo/Seryices/cm/Mubspot/Service.ohdAutnorDate:Mon May 4 12:17: 85 2826 +8306JY-20887 | Update deleted stages, restore in recreatedindex 9885 6c50/562481853 180spot/Service.php b/app/Services/Cra/Hubspot/Service. php=The May 4 change only added withTrashed() and the soft-delete restore logic. The probability line is unchanged - it was ssI'probability') * 100Now let me look at the commit before this one to see what inportStages looked like originally:B aft choy 262200/56d--100/Carfcac/Crn/Hubsoot/Carufca.nhoDate:Mon Kay Ty12:19:47 2826-48380V8J Sainny. ComeJY-20807 | Restore 1o9intex 5246 8/a30/servises/61 186b5sot/Service,oho o/apo/services/cre/Hubspot/Service.ohg100644- з/aрр/Services/Crm/Hubspot/Service.phpoat og moneune arcetionbo aoetores"28%-84-78" ae no0/Sericesleretosporsericeonort contacts and import accounts to batch job & change sync approach0 git show c6838d8a29 - app/Services/Crm/Hubspot/Service-phpAsk anything (XOL)Adhet6xRun xe (Skip• OKtwodeurlhtmeshires2 4 spac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88367
|
|
88366
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
rapstomEV faVsco,ls ~ProjectvViewCooc#12121 on JY-20963-fix-InHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol Cimosewhoo© ResponseNormalize.phpCSeMCPonoexisiinastaad© SyncFieidAction.php10.02.23 Vasilev© synckelateoncuvnywanas 20.07.23 toni-yminny© WebhookSyncBatchProce20.07.23 toni-jiminny20.07.23 toni-fiminnylisteners20.07.23 toni-jiminny> Metadata20.07.23 toni-fiminnyMigration30.09.24 PapazovP oedrive20.07.23 toni-jiminnySalesforce19.03.18 Grahamaleelds19.03.18 GrahamOpportunityMatcher9.05.18OpportunitySyncStrategyGrahamProspectSearchStrategy19.03.18 GrahamUl taoro19.03.18 Graham© Cient.php2.04.18Olahan© DecorateActivity.php24.04.18 GrahamG DeleteObjectsTrait.phpewero oranat©FieldDefinitions.php© PayloadBuilder.php3.10.25© Profile.php3.10.25©QueryBullder.php©QueryHandler.php©Querylterator.php©QueryResults.php© Service.phpKHOS NIKOO10.09.25 ilian74.04.18 Graham24.04.18 Graham9oxh8 Graham© SyncBatchRedisService.ptTraits© BaseCllent.phpcrahem19.03.18 Graham© BaseService.php© CachedCrmServiceDecorator2.10.25© CrmActivityProviderintegrateCCWCMWMoronCermconticurationSettinass2.10.25©rmobiectsteso wer.ono17.03.25 flian© DefaultProspectSearchStrate16.04.25 IvanovC mallteloer.ond2.10.25Nikolov3Findeproscectinterace.ono2.04.18GrahamC) LavouMansoe ono® MatchDomainByEmailinterfac 2.04.18GrahamC Opportun tvActvitwlatcheee19.03.18 Graham® OpportunitySyncStrategyinte2.04.18Citanaiteennortur wewn.CtestomedProspectCache.php20.10.21 GrahamHe OrnenontCostrhSrond nhnentity // View pull request (today 16:12)WindowU ServiceTest ~TO0У L7Thu 28 May 19:59:44+0.© DeleteObjects Trait.phpuorcoaiuocrd.pntRecordSelector.php© Activity.php© Team,php© HubspotLastModifiedCreatedRecentlyOpenSyncStrategy.phpE custom.logE laravellogA HSJJocal (jiminny@localhost]A console (STAGING]Cascade# СОЛЬОС PHUA console (EU] x ( users (EU)les Orcnnworeebee.ionHlomwlino oooorstd te, restore in recrestedClost oias no seiveironx5 Cc334 C336337382384386 €389407409421413)415class Service extends BaseService inplements* Bcetucn EieldDatal]170601 47 A149 X1 233 21 A v 1707-17081709public function inportPicklistValues(17101711Eleld Sfield,1114array Soptions = [l'id''Label' »> "*, "value' »> **]].): array {..)-1717-1718public function inportStages(Parray $types = null, Pstring SaissingStageNane = null): ?Stage-1728=1720whesoernorTITE// Use the HubSpot API client instead of the SDK crnPipelines() nethodsendooint a selt..oerteasproeanesendoonroSpipelinesResponse = Sthis-›client->getInstance() ->getClient() -request( method:) "GET'.Soioeiaines a sotoeluneskesponse-›data-›resu.ts} catch (RequestException|BadRequest Sexception) ({1726=1729=1730foreach (Spipelines as Spipeline) €NstaceseeE7sa-1733// We create a business process to contain the pipeline, and store all stages against=1735Sp = ResponseNornalize::nermalizeP$peline(Spipeline);1736-1737// Create/update business process for this pipelineSbusinessProcess = Sthis->config->businessProcesses()->update0rCreateCC'ern_provider_id' => $p['id'],1. E"tean_id'"name'ype-1738E17391740=1742=> Sthis->team->id,=> mb_strimwidth(Sp('Label'), (start: 0, (width: 150),=> BusinessProcess:: TYPE_OPPORTUNITY,=> $p['active').—1742=1743=1744=1745D):I1 A record type is really a clone of the business process, used to store nhich recond ves 1748.// Create/update record type cloneSthis->config->recordTypes()->update0rCreateCf'crn_provider_id' => Spl'id'],111Thc AutoORDER BY t.nane, u.email;Se jiminny~031 A9 A29 V3 /109 A VSELECT * FROM teans WHERE nane LIKE "XTourlane%'; # 187, 209, 8154.SEEiNTCONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN ' (owner)' ELSE-Mrenasisa.*,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.nc->1: on t.id = u.tean_idWHERE u.tean_id = 187 and sa.provider = 'salesforce':• git show 8ea51001fe -statcomait 8e351d81fe32815765cfe95ba8debdßae18311eeDate:Mik tay 1 12217:06212y-4ygnovey/1miny. comsJY-28897 | Update deleted stages, restore in recreated=There it is. The May 4 commit is JY-20987 | Update deleted stapes, restore in recreated . Let me see the full ditt:select * fron activities nhere id = 31264367:select * fron contacts where id = 6331639;aeleck x tron accoun ancht o-450054select * fron opportunities where id = 4843610;#Uodare0c0un8-4300-4"contact_id' = 6.#"stage_id' = 13273,"updated_at' = 2026-0S-22 07:16:• git show 8ea51d01fe — app/Services/Crn/Hubspot/Service.phpselect * fron text_relays where created_at > '2026-05-01':Mon May 4 12:17:06 2026 +6306JY-20887 | Update deleted stages, restore in recreatedne, apa ao e e seritce-pha /sp/ servites/ rm/mhbspot/seritce,.pselect * fron actvales order oy 1d descaThe May 4 change only adocd withtrashed and the solt-delete restore logic. The prcoaostity line is uncha.ocd — it was ssprobabilty*10select * fron users where nane oike "subrasaNow let me look at the commit betore this one to see what inportStages looked like oricinalsveSELECT * FROM opportunities HHERE wwid_to_bin('04a9cfad-2c87-4453-sselect * fron teans where 1d= 555%select * fron stages where tean_id = 555;SETSTMT• oft show 34229045hd - ano/Services/Crn/Hubspot /Service, oheCONCAT(U.Ld, CASE WHEN U.1d = t.ouner_id THEN " (owner)" ELSEMon May 4 12:19:47 2826 +8306sa.*,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idahittinnetneosonettsWHERE u.tean_id = 100 and sa.provider = 'hubsRes":hoey 2a0 a ee p e serito php b/sp/Servies/Cm/hbsper/Servite,.9rpsCommand citselect id, is_closed, is_won, stage_updated_at, crm_provider_id, stclose_date, forecast_category, deleted_at, created_at, renotely_crtfron opportunities where tean_id = 555 and stage_id = 20616 order]select id, crn.provider.id, nane, probability, updated.at, deleter!fron stageswhere id = 28616;O git Log —oneline -after="2825-18-01" -before="2026-04-20" - app/Services/Cra/Hubspot/Service-phpRun ste (SkipAsk anything (XOL)Adhet• OKtwodeurlhtmeshires2 4 spac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88366
|
|
88365
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:59:44®8 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88365
|
|
88364
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
rapstomEV favscojs ~ProjectvViewCooc#12121 on JY-20963-fix-InHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol Cimosewhoo© ResponseNormalize.phpCSeMCPonoexisiinastaad© SyncFieidAction.php10.02.23 Vasilev© synckelateoncuvnywanas 20.07.23 toni-yminny© WebhookSyncBatchProce20.07.23 toni-jiminny20.07.23 toni-fiminnylisteners20.07.23 toni-jiminny> Metadata20.07.23 toni-fiminnyMigration30.09.24 PapazovP oedrive20.07.23 toni-jiminnySalesforce19.03.18 Grahamaleelds19.03.18 GrahamOpportunityMatcherOpportunitySyncStrategy9.05.18GrahamProspectSearchStrategy19.03.18 GrahamUl taoro19.03.18 Graham© Cient.php2.04.18Olahan© DecorateActivity.php24.04.18 GrahamG DeleteObjectsTrait.phpewero oranat©FieldDefinitions.php© PayloadBuilder.php3.10.25© Profile.php3.10.25©QueryBullder.php©QueryHandler.php©Querylterator.php©QueryResults.php© Service.phpKHOS NIKOO10.09.25 ilian74.04.18 Graham24.04.18 Graham9oxh8 Graham© SyncBatchRedisService.ptTraits© BaseCllent.phpcrahem19.03.18 Graham© BaseService.php© CachedCrmServiceDecorator2.10.25© CrmactivityProviderintegrateCCWCMWMoronCermcontcurationsettinosse2.10.25©rmobiectsteso wer.ono17.03.25 ilian© DefaultProspectSearchStrate16.04.25 IvanovC mallteloer.ond2.10.25Nikolov3sindeProscectinternce.ono2.04.18GrahamC) LvoutMansoeono® MatchDomainByEmailinterfac 2.04.18GrahamC Opportun tvActvitwlatcheee19.03.18 GrahamOpportun tySyneStratccvinte2.04.18Citanaiteennortur wewn.CtestomedProspectCache.php20.10.21 GrahamHe OrnenontCostrhSrond nhnentity // View pull request (today 16:12)WindowTO0У L7Thu 28 May 19:59:41U ServiceTest ~+0.© DeleteObjects Trait.phpuorcoaiuocrd.pntRecordSelector.php© Activity.php© Team,php© HubspotLastModifiedCreatedRecentlyOpenSyncStrategy.phpE custom.logE laravellogA HSJJocal (jiminny@localhost]A console (STAGING]# СОЛЬОС PHUA console (EU] x ( users (EU)Cascadeles Orcnnworeeoeionhmwesotino ooroClost oias no seiveironx5 Ccclass Service extends BaseService inplements170601 47 A149 X1 233 21 A v 1707SELECT * FROM teans WHERE nane LIKE "XTourlane%'; # 187, 209, 8154.* Bcetucn EieldDatal]-1708SEEiNT1709CONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN ' (owner)' ELSE-334 C336337382public function inportPicklistValues(1710Mrenasi1711Eleld Sfield,sa.*,1114t.ouner_id FROM social_accounts saarray Soptions = [l'id''Label' »> "*, "value' »> **]1.JOIN users u on u.id = sa.sociable_id): array {..)JOIN teans t 1.nc->1: on t.id = u.tean_idWHERE u.tean_id = 187 and sa.provider = 'salesforce':384-1717select * fron activities nhere id = 31264367:-1718386 €public function inportStages(Parray $types = null, Pstring SaissingStageNane = null): ?Stage-1728select * fron contacts where id = 6331639;aeleck x tron accoun ancht o-450054=1720select * fron opportunities where id = 4843610;Dnesoer nork389// Use the HubSpot API client instead of the SDK crnPipelines() nethodsendooint a selt..oertea.sproebnesandoonrorTITE#Uodare0c0un8-4300-4"contact_id' = 6.#"stage_id' = 13273,"updated_at' = 2026-0S-22 07:16:select * fron text_relays where created_at > '2026-05-01':SpipelinesResponse = Sthis-›client->getInstance() ->getClient() -request( method:) "GET'.{1726select * fron actvales order oy 1d descaSoioeiaines a sotoeluneskesponse-›data-›resu.ts} catch (RequestException|BadRequest Sexception) (select * fron users where nane tuike "Subrasa=1729=1730SELECT * FROM opportunities HHERE wwid_to_bin('04a9cfad-2c87-4453-sforeach (Spipelines as Spipeline) €NstaceseeE7saselect * fron teans where d = 555%-1733select * fron stages where tean_id = 555;SETSTMT// We create a business process to contain the pipeline, and store all stages against=1735CONCAT(U.Ld, CASE WHEN U.1d = t.ouner_id THEN " (owner)" ELSESp = ResponseNornalize::nermalizeP$peline(Spipeline);1736sa.*,-1737t.ouner_id FROM social_accounts sa// Create/update business process for this pipelineSbusinessProcess = Sthis->config->businessProcesses()->update0rCreateCC-1738JOIN users u on u.id = sa.sociable_id407'ern_provider_id' => $p['id'],E1739ahittinnetneosonetts1. E1740WHERE u.tean_id = 100 and sa.provider = 'hubsRes":=1742409"tean_id'"name'=> Sthis->team->id,—1742select id, is_closed, is_won, stage_updated_at, crm_provider_id, st421=> mb_strimwidth(Sp('Label'), (start: 0, (width: 150),=1743ype=> BusinessProcess:: TYPE_OPPORTUNITY,=1744close_date, forecast_category, deleted_at, created_at, renotely_crtfron opportunities where tean_id = 555 and stage_id = 20616 order]413)D):=> $p['active').=1745select id, crn_provider_id, nane, probability, updated_at, deletere415I1 A record type is really a clone of the business process, used to store nhich record Vet 1748.fron stageswhere id = 28616;// Create/update record type cloneSthis->config->recordTypes()->update0rCreate(t'crn_provider_id' => Spl'id'],111Thc AutoORDER BY t.nane, u.email;Se jiminny~031 A9 A29 V3 /109 A Va1t loa =oneline =тolloи =aтter= 2026-84-20" -octore=2026-05-10 app/Services//cra//Mubspot/Service.phpUpater lletod stapes, restore in recrestedi Add permission logging for HubspotCommand cl• qit show 8es51d01fe -statJY-28897 1 Uodate deleted staoes, restore in recreatedapp/3ers/es/Craste5spoe/Stppp/Shurn/0s/Cte/rwbonotsratlon-pndler,phptinere tisihe woy & commreosare deleted stases. irestore inrecrentoo.emosotCommand git• qit show 8ea51d91fc = apo/Services/Crm/Hubspot /Servicc.ohr: Non Kay 4 12:17:86 2825 -9300)wgjiminny.com›1Y-28887 Uodate deleted staoee, restore in recrentedindex 988% /50/5e28es/50 /Mu6sesot/Service-php b/app/Services/Crm/Hubspot/Service.phpaioe Mayz chaoon only nd dh vinhinche lêu nodlnh colicdentn rostom ogic heu mhamiTy lnelk unchanocd e RwnR Ka LnconnhiTvà LTbefore and after.Nowat malook nn ths comet Cetors the dod to thn whttnnortSansellod tholike orie dal• ait chou 312290d50d e ano/Senyiices/Crm//Mubsnot /Service,ohoconnit 3+2298d5bd62518248b6e7d0d416c6420d748d84Dathor: Mon Kay Zy12019:47 2826-te3nove/ tninny. com»JY-20887 | Restore 1ogd1tt -gtt a/app/Servtces/Cra/Hubspot/Service.php b/app/Services/Cre/lubspot/Service.phpAsk anything (XOL)ceodhAdhet• OKowodeurTetme2 4 space...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88364
|
|
88363
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lproidetHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoCSWnCKealCohe wWwK© WebhookSvncBatchProcelisteners> MetadataaMicrationP oedriveEh SalesforceafeldsaOpoortunityVatchenOpportunitySyncStrategyProsoec SaarchStrateosametiteê crant nhoC DecorateActivity.php( DeletcObiectsTrait.phposwan ntone non© PayloadBuilder.phpc) Profile.phpc) OueryBulderonp© QueryHandler.php© Queryiterator.php© QueryResults.php© Service.php© SyncBatchRedisService.pin TraitsC BaseClient oho© CachedCrmServiceDecorato( CrmActivitvProviderinteorateCCWCMWMoron©rmobiectsteso wer.onoC. DefaultProsoectSearchStrateC mallteloer.ond@. FindeProsoectinterfacc.ohoC) LavouMansoe ono8. MatchDomainByEmalllntorfacexisiinastaad10.02.23 VasilevDaoosnszouMow&GrahamWow?crahameloke?Grahamcrahami19.03.18 Graham2.04.18Orandm710252.10.2520.10.21 Graham2.10.251US. Wlhh16.04.25 Ivanos2.10.25"A4012.04.18Graham13ox8 Graham2.04.1820.1021 Graham2.[IP_ADDRESS] vanoy210.258.11.16crahemi19.03.18 Grabam4.[IP_ADDRESS] Ivanov405284.05.26C Opportun tvActvitwlatcheeeennortur wewn.Ctestomedrnenont tschd nhrHe OrnenontCostrhSrond nhn4.05.26Ctty lwew ou teoueet today lhayWindow398402404406408489)[PHONE]3143343S437sraveloe© RecordSelector.phgC) ACUVIY.or.pC) Team.phd# HS local [liminny@localhost# Conboc PhoA console (EU) x iii users (EU)# console (STAGINGTx: AutovGo liminny vBROER PYTnane, M.emare031 A9 A29 V3 /109 A Vclass Service extends BaseService implements01 A7 A149 V1V33 /1 A v 170%public function importStages(?array Stypes = null, ?string SnissingStageNane = nul)): ?Stage1706—1708SELECT * FRON teans WHERE name LIKE "stounlanes: # 187, 289, 8158SEEiNTCONCAT(U.1d, CASE WHEN U.10 = t.ouner_1d THEN" (owner)" ELSEthnow sexcept..on171612111Mrenasiforeach (Spipelines as Spipeline) 11713Sstages = lJsa.*t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.nc->1: on t.id = u.team_1dWHERE u.tean_id = 187 and sa.provider = 'salesfonce':// We create a business process to contain the pipeline, and store all stages against it.$p = ResponseNornalize::normalizePipeline(Spipeline):-121%// Create/update business process for this pipelineSbusinessProcess = Sthis->confiq->businessProcesses@->update0rCreated'craprovider id' => Spl'id'))-1728= 1726select * fron activities where id = 31264367select * fron contacts where id = 6331639:select * fron accounts where id = 4156632:select * fron opportunities where id = 4843610:#Uodareacove#"stage_id' = 13273300n8-4300-4"contact_id' ="updated at" = 2826-95-22 07:16:tase4a.o ewwornooLooestdlc o= BusinessProcess::TYPEOPPORTUNITYwidth: 158)select * fron text relavs where created at > '2926-95-91°:kecooktaoaceVeselect * fron actviates order oy 1d desc;select * fron users where nane oike SubrasaIl A record type is really a clone of the business process, used to store which recorderearel uodare record tvoe ciloneU5T 1729Sthis->config->recordTypes()->update0rCreateC[=17301173)=> 5010'10E1732—1733=> Sthis->tear->id.=> nb stcinwidth(Sof"label'1.Start 8hwiatt:s158)=1735Is salectahle"Sallacrave"business onocess {a! es ShusinessPoocess-$1d 22 oul1l=1736E17571738= 1755Vmacse - Terchail aysetind cradde lunfront to avoa ltouensaeshydetinosthons= Sthse-sconfsa-setaans1746=1742himche.=1742SELECT * FROM opportunities WHERE wnid to_bin('04a9cfad-2c87-4453-$|select * fron teans where 1d= 555%select * fron stages where tean_id = SSS;SETSTMTCONCAT(U.Ld, CASE WHEN U.1d = t.ouner_id THEN" (owner)" ELSEhonnen siil coesasccounteeJOIN users u on u.id = sa.sociable_idahittinnetneosonettsWHERE u.tean_id = 100 and sa.provider = 'hubspot":->ahere("type", Stage::TYPE_OPPORTUNITY)-sedtal=114select id, is_closed, is_won, stage_updated.at, crm_provider_id, stclose date, forecast category, deleted at, created_at, remotely-crtfron opportunities where tean id = SSS and stageid = 28616 order_SkawRultean anouidon iar-17451746 %select sid, ern provider id, nane, probability, uodated at, deletoeforeach (Sp["stages'] as $dealStage) 4$s = ResponseNormalize::normalizeDealStage(SdealStage)—1/40Gnon etanodwhere id = 28616/** @vac ?Stage SexistingStage *,SexistinoStage = SexistingStages-›get(Ss($id'):Cascadeles Orcnnworeeoeionhomwwtino ooooTO0У L7Thu 28 May 19:59:36ServiceTest+0.• qit log =oneline «follou -aftera*2826-84-20" =beforea"2026-05-19" app/Services/Crm/Hubspot/Scrvicc.phrestore n recreated1 Add pernission logging for Hubspot• git show 8ca51d01fe -statouteir: NonoLay E 12017:052126-10380ve/tninny. comsJY-20807 Update deleted stages, restore in recreatedThere it is. The May 4 commit is JY-28887 | Update deleted stages, restore in recreated. Let me see the full dift• qit show 8ea51d01fe - app/Services/Crn/Hubspot/Service.phgMik lay Tr 12017:06 2125- 4y3n0e/ tainny. ComsJY-28887 Uodate deleted staocs, restore in recreatedindex Sast 3/c5P/S6248183230 180658not/Seryice, pho b/aoo/Saryices/Cm/Mubsoot /SeryicerohThe May 4 change only added withtrashed()) and the soft«delete restore logic. The probsbillity line is unchanaed = it was ss("probsbiUity'] * 180Now let me lock at the commit before this one to see what inportStages looked like oricinallCAmmanA diio qt show srzzedsoda apo/Servces/crm//Hubspot/Service.ohgRunxtw Skiprt aeuthientuniAdhet• OXwodeurlhime"eshirest4 spag...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88363
|
|
88362
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:59:36®8 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88362
|
|
88361
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoCSWnCKealCohe wWwK© WebhookSvncBatchProcelisteners> MetadataaMicrationP oedriveEh SalesforceafeldsaOpoortunityVatchenOpportunitySyncStrategyProsoec SaarchStrateosametiteê crant nhoC DecorateActivity.php( DeletcObiectsTrait.phposwan ntone non© PayloadBuilder.phpc) Profile.phpc) OueryBulderonp© QueryHandler.php© Queryiterator.php© QueryResults.php© Service.php© SyncBatchRedisService.pin TraitsC BaseClient oho© CachedCrmServiceDecorato( CrmActivitvProviderinteorateCCWCMWMoron©rmobiectsteso wer.onoC. DefaultProsoectSearchStrateC mallteloer.ond@. FindeProsoectinterfacc.ohoC) LvoutMansoeono8. MatchDomainByEmalllntorfacexisiinastaad10.02.23 VasilevDaoosnszouMow&GrahamWow?crahameloke?Grahamcrahami19.03.18 Graham2.04.18Orandm710252.10.2520.10.21 Graham2.10.251US. Wlhh16.04.25 Ivanos2.10.25"A4012.04.18Graham13ox8 Graham2.04.1820.1021 Graham2.[IP_ADDRESS] vanoy210.258.11.16crahemi19.03.18 Grabam4.[IP_ADDRESS] Ivanov405284.05.26C Opportun tvActvitwlatcheeei lennortur tevnaCtestomiedirnenont tschd nhrHe OrnenontCostrhSrond nhn4.05.26Ctty lwew ou teoueet today lhayWindowTO0У L7Thu 28 May 19:59:33[PHONE]06408489)[PHONE]3143343S437sraveloe© RecordSelector.phgC) ACUVIY.or.pC) Team.phd# HS local [liminny@localhost# ConbocrhouA console (EU) x iii users (EU)# console (STAGINGTx: AutovGo liminny vBROER PYTnane, M.emare031 A9 A29 V3 /109 A Vclass Service extends BaseService implements01 A7 A149 V1V33 /1 A v 170%public function importStages(?array Stypes = null, ?string SnissingStageNane = null): ?Stage1706—1708SELECT * FRON teans WHERE name LIKE "stounlanes: # 187, 289, 8158SEEiNTCONCAT(U.1d, CASE WHEN U.10 = t.ouner_1d THEN" (owner)" ELSEthnow sexcept..on171612111Mrenasiforeach (Spipelines as Spipeline) 11713Sstages = lJsa.*t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.soclable_idJOIN teans t 1.nc->1: on t.id = u.team_1dWHERE u.tean_id = 187 and sa.provider = 'salesfonce':// We create a business process to contain the pipeline, and store all stages against it.$p = ResponseNornalize::normalizePipeline(Spipeline):-121%// Create/update business process for this pipelineSbusinessProcess = Sthis->confiq->businessProcesses@->update0rCreate'craprovider id' => Spl'id'))-1728= 1726select * fron activities where id = 31264367select * fron contacts where id = 6331639:select * fron accounts where id = 4156632:select * fron opportunities where id = 4843610:#Uodareacove#"stage_id' = 13273300n8-4300-4"contact_id' ="updated at" = 2826-95-22 07:16:tase4a.o ewwornooLooestdlc o= BusinessProcess::TYPEOPPORTUNITYwidth: 158)select * fron text relavs where created at > '2926-95-91°:kecooktaoaceVeselect * fron actviates order oy 1d desc;select * fron users where nane uike subras:Il A record type is really a clone of the business process, used to store which recordereareluodate record tvoe clongU5T 1729Sthis->config->recordTypes()->update0rCreateC[=17301173)=> 5010'10E1732—1733=> Sthis->tear->id.=> nb stcinwidth(Sof"label'1.Start 8hwiatt:s158)=1735Is salectahle"Sallacrave"business onocess {a! es ShusinessPoocess-$1d 22 oul1l=1736E17571738= 1755Sacse - Terchain aysetnd crsane linfront to avosd ltouensaeshydetinosthons= Sthse-sconfsa-setaans1746=1742himche.=1742SELECT * FROM opportunities HERE unid to_ bin('84a9cfad-2c87-4453-S|select * fron teans where 1d= 555%select * fron stages where tean_id = SSS;SETSTMTCONCAT(U.Ld, CASE WHEN U.1d = t.ouner_id THEN" (owner)" ELSEonnenstil socal nccounteeJOIN users u on u.id = sa.sociable_idahittinnetneosonettsWHERE u.tean_id = 100 and sa.provider = 'hubspot":->ahere("type", Stage::TYPE_OPPORTUNITY)-sedtal=114select id, is_closed, is_won, stage_updated.at, crm_provider_id, stclose date, forecast category, deleted at, created_at, remotely-crtfron opportunities where tean id = SSS and stageid = 28616 order_SkawRultean anouidon iar-17451746 %select sid, ern-provider id, nane, probability, undated at, deletos:foreach (Sp["stages'] as $dealStage) 4$s = ResponseNormalize::normalizeDealStage(SdealStage)—1/40Gnon etanodwhere id = 28616/** @vac ?Stage SexistingStage *,SexistinoStage = SexistingStages-›get(Ss($id'):meesdales Orcnnworeeoeionhwmwlino oooNow let me check what this method looked like before May 4 - specifically the git log• git log -oneline -follou -20 app/Services/Crm/Hubspot/Service.phpades" pod "geoortRecoresvess» are Cleaned uo tron all CRus but Selesforce.• qit log -oneline -follow -after="2826-84-20" -before="2026-05-10" app/Services/Crm/Hubspot/Service.php317063td Ye7 | Bestore eiarod stoes, dractard nrorrastahfd44984613 JY-29352 Add pemiasion lonaina for Hubsoot.• git show 8ea51d01fe -statcomnit besxodolteazsts76octes50s50ebdsseleshtecDate:Mon lay 1 32:27:06 22543nore/1niny. comJY-20807| Update deleted stages, restore in recreatedapp/30rs/es/Castessaoe/SerThere it is. The May 4 commi is Jyozeee Update deleted stages, restore in recreated. Let me see the ful dim• qit show 8ea51d91fe - apo/Services/Crm/Hubspot/Service.ohrMon May 4 12017:06 2626 19380JY-20887| Uodate deleted stages, restore in recreateddnde sust s/c00/Sezvices/Co /ubspot/Service.php b/app/Services/Cra/Hubspot/Service.php- a/apo/Saryices/cr/Mrbendt/aruien nhdThe May 4 change only added withtrashed(0 and the soft«delete restore loaic. The orobabtllity line is unchanoed = it was ss ("erohabtWtv") x 100baford snd aftneNow let me lock at the commit before this one to see what inoortStages looked like oricinallvAsk anything (XoL)Adhet• OKwweunamet4 spad...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88361
|
|
88360
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'ac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88360
|
|
88359
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoexisiinastaad1ooyzs Vasiiow© synckelateoncuvnymanas 24.01.25 Papazov© WebhookSvncBatchProce19.03.18 Grahamlisteners> Metadata4.05.26AnsoelaMioration4.05.26P oedriveEh Salesforce4.05.204.05.26afelds4.05.20aOpoortunityVatchenOpportunitySyncStrategyProsoec SaarchStrateo3.10,25sametite@ Client.phpalmaeoratdett ooe( DeletcObiectsTrait.phpoewanarinithooe nha4.05.264.05.264.05.284.05.26© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.phpeQuerviterator.oh© QueryResults.php© Service.php© SyncBatchRedisService.pth TraitsRaseeentono© BaseService.cho© CachedCrmServiceDecorato40524052340524.05.2640522.10.2517.03.25 llian4.05.264.05.264.05.26CrmActivityProviderinteorateCCnlACMiWMohCermcontcurationSettnosser10.0218 Crohom©rmobiectctesower.onC.DefaultProsoectSearchStrat.C mallteloer.ond2.04.18Grahan19.03.18 GrahanC) LavouMansoe ono204.18eroxh Graihiim(© OpportunityActivityMatcher.p 19.08.18 GrahamGranhhimei lennortur tevnaCtestomiedi19.08.18 Grahamrnenont tschd nhrerekha GrahanHe OrnenontCostrhSrond nhn19.08.18 GrahamCtty lwew ou teoueet today lhayWindow425427429430[PHONE]56458466sraveloe© RecordSelector.phgC) ACUVIY.or.PC) Team.phd# HS local [liminny@localhostA console (EU) x iii users (EU)(© HubspotLastModifiedCreatedRecentlyOpenSyncStrategy.phcconsole (STAGINGCiwhoocwoneTx: AutovSo liminnyvBROER PYTnane, M.emare031 A9 A29 V3 /109 A Vclass Service extends BaseService implements01 A7 A149 V1V33 /1 A v 170%public function importStages(?array Stypes = null, ?string SnissingStageNane = null): ?Stage1706— 1705SELECT * FRON tEaNS WHERE name LIKE "Stounlanes: # 187, 289, 8158SEEiNT/ Stages • fetch all existing stages upfront to avoid N+1 querieses = Sthis-›config-›stages)-withTrashed->ahere("type", Stage::TYPE OPPORTUNITY)->get)okeyby cre provzder 2oTorecheosaoesswealadeSs = ResponseNornaTize::normalizelealStage(SdealStace)/** @vac ?Stage SexistingStage */SexistingStage = SexistingStages->get($s[*id'J):M…eworesorotreosoesire non sheyentuooif (SexistingStage?->trashed() && $s['active') 4SexistingStage->restore®:// Upsert stage (updates soft-deleted records without restoring them)Sstage = Sthis->config->stages()->withurashed->update0rCreateCl"crn provider id' => $s['id'].→SthisosteanosidhtnhctnrwdthceltnholtehrtaweahCanli=> mb strinwidth(Ss['label'1.Stage::TYPE OPPORTUNITY,> Ss('displayOrder')'is selectable' => $s('active').=> Ss['probability' * 108,D):if (SnissingStageName a== $s("id')) (saissanostaoe a sstage.SScages = Sscage->205hustnes.Phoccssowmoes0eseyneleshodsCetum teieSineSnee"171612111— 1715-1728=172017261728-173017391=1732—1733-173581736E17571738= 17551703=1742=1742=114-17451746 %CONCAT(U.1d, CASE WHEN U.10 = t.ouner_1d THEN" (owner)" ELSEMrenasisa.*t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.soclable_idJOIN teans t 1.nc->1: on t.id = u.team_1dWHERE u.tean_id = 187 and sa.provider = 'salesfonce':select * fron activities where id = 31264367select * fron contacts where id = 6331639:Belecekx tron accoun ancheo- 450054select * fron opportunities where id = 4843610:#Uodare#"stage_id' = 132730c0un8-4300-4"contact_id' ="updated at" = 2826-95-22 07:16:select * fron text relavs where created at > '2926-95-91°:select * fron actviates order oy 1d desc;select * fron users where nane uike subras:SELECT * FROM opportunities HERE unid to_ bin('84a9cfad-2c87-4453-S|select * fron teans where 1d= 555%select * fron stages where tean_1d = 555:SETSTMTCONCAT(U.Ld, CASE WHEN U.1d = t.ouner_id THEN" (owner)" ELSEsa.*,onnenstil socal nccounteeJOIN users u on u.id = sa.sociable_idahittinnetneosonettsWHERE u.tean_id = 100 and sa.provider = 'hubspot":select id, is_closed, is_won, stage_updated.at, crm_provider_id, stclose date, forecast_category, deleted_at, created_at, remotely_crtfron opportunities where tean id = SSS and stage_id = 28616 orderGelect ia, erm provider id, nane, probabizity, undated ot, deletee—1/40fron stageswhere id = 28616TO0У L7Thu 28 May 19:59:26ServiceTestCecsdales Orcnnworeebeeionhomwstotino ooso+0.Review again @Service.php#L386-468 as it has been changed on 2026-05-04. Could there be some potential issueLet me road the current state of fraartSthoescaretuiheNow let me check what this method looked like before May 4 - specifically the git log• qit lod-oneline -follow -20 app/Services/Crm/Hubspot/Service.phprestore in recreatedtestses are Cleanco uo tron all erys but Salestorce• qit show 8ea51d01fe -statconmit 8ea51d0.feazst5/66cfe95ba8debd8ae18311egMik lay Tr 1207:06 2125- 4y380ve/ tninny. comsJY-28887 Uodate deleted staoes, restore in recreatedspa/drca/eat/estae/.enmice.e2/ er. h.)There it is. The May 4 commit is 7Y-29897 | Uodate deleted stages, restore in recreated. Let me see the full dittCommand c0 git show 8ea51001fe - app/Services/Cra/Hubspot/Service.phpRunxtw SkipAsk anything (XOL)ceodhAdhet• OXwodeurlhime"eshirest4 spad...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88359
|
|
88358
|
HomeDMsActivityFilesLater..•More+Slack> 0(ah]Fi HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:59:26®8 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88358
|
|
88357
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
rapstomEV faVsco,ls ~Cooc#12121 on JY-20963-fix-InHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol Cimosewhoo© ResponseNormalize.phpCSeMCPono© SyncFieidAction.phpCSWnCKealCohe wWwK© WebhookSyncBatchProceexisiinastaad10.02.23 Vasilev24.01.25 Papazovlisteners> MetadataMigrationP oedrive2.10.258.11.182.04.18Graham19.03.18 GrahamSalesforce4.05.26afeldsOpportunityMatcher4.05.20OpportunitySyncStrategyProspectSearchStrategysametite© [EMAIL] DeleteObjectsTrait.php©FieldDefinitions.php© PayloadBuilder.php© Profile.php4.[IP_ADDRESS].[IP_ADDRESS].10.254.05.264.05.264.05.26©QueryBullder.php©QueryHandler.php©Queryiterator.php4.05.26©QueryResults.php© Service.php4.05.26© SyncBatchRedisService.ptTraits© BaseCllent.php© BaseService.phpA05.282.10.2517.03.25 Hhan© Cached Crm Service Decorator© CountryCodeResolver.php© CrmActivityProviderintegrateA05.28© CrmActivitvService.ohdCermcontcurationsettinosse4.05.26©rmobiectctesower.on4.05.264.05.26© DefaultProspectSearchStrateC mallteloer.ond4.05.262.04.18GrahamFindsProspectinterface.php19.03.18 GrahamC) LvoutMansoeono® MatchDomainByEmailinterfac2.04.18GrahamC Opportun tvActvitwlatche19.03.18 Grahamei lennortur tevnaCtestomiediProspectCache.phpHe OrnenontCostrhSrond nhn19.03.18 Graham19.03.18 Grahamentity // View pull request (today 16:12)Window38€421423425427429431450452454RecordSelector.php© Activity.php© Team,phpA HSJJocal (jiminny@localhost]A console (STAGING)A console (EU] x ( users (EU)class Service extends BaseService inplements170601 47 A149 X1 X33 21 A V 1707public function importStages(Parray Stypes = null, Pstring SnissingStageNane = null): 2Stage-1708= nb_strimwidth($p['label'],start: e,1709150),is selectable'=› $p['active'),1710*business process id' => $businessProcess->id 2? null.171117121713// Stages - fetch all existing stages upfront to avoid N+1 queriessexistingstages = Sthis->config->stages()->withTrashed()->ahere('type', Stage::TYPE_OPPORTUNITY)→>oel-keyoycreprovzder20foreach (Sp['stages'] as SdealStage) ($s = ResponseNornalize: :nornalizeDealStage(SdealStage);/** Bxar 2Stoge SexistingStage */SexistingStage = SexistingStages->get(Ss['id'D);I/ Restore soft-deleted stages that are now active in HubSpotif (SexistängStage?-»trashed() && $st'active')) €SexistingStage->restore():// Upsert stage (updates soft-deleted records without restoring then)Sstage = Sthis->config-›stages()->withTnashed->update0rCreate(l'crn_provider_id' »> Ss('1d').Sthis->team->id,enhstrsmm dthSor'nahet"enb_strimwidth($s['label'],Stage::TYPE_OPPORTUNITY,→ $s['dispLayOrder'],'is_selectable' => $s['active'),onoosottrty→> $s['probability'] * 100,wdthe 59191).D):17151716-1717E171s=1720-173)-1733E1755=1736-1733-1738E17391740=1742—1742=1743=1744=17451746 v— 1/40—1748Thc AutoORDER BY t.nane, u.email;Se jiminny~031 49 A29 X3 X109 A VSELECT * FROM teans WHERE nane LIKE "XTourlane%'; # 187, 209, 8154.SELECTCONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN ' (owner)' ELSE-u.enail,sa.x,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.nc->1: on t.id = u.tean_idWHERE u.tean_id = 187 and sa.provider = 'salesforce':select * fron activities where id = 31264367:select * fron contacts where id = 6331639;select * fron accounts where id = 4156632;select * fron opportunities where id = 4843610;# updateacove300n8-4300-4"contact_id' = 6.# "stage_id' = 13273,"updated_at' = 2026-05-22 07:16:select * fron text_relays where created_at > ^2026-05-01';select * fron activities order by id desc;select * fron users where nane uike "Subras?SELECT * FROM opportunities HHERE wuid_to_bin('04a9cfad-2c87-4453-sselect * fron teans where 1d= 555%select * fron stages where tean_id = 555;SETSTMTCONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN ' (owner)' ELSE:lhenasa.*,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.n<->1: on t.id = u.tean_id11111WHERE u.tean_id = 100 and sa.provider = 'hubsRes":if (SaissingStageNane a== $s['id'I) 4saissinoStaoc = Sscaockselect id, is_closed, is_won, stage_updated_at, crn_provider_id, atclose_date, forecast_category, deleted_at, created_at, renotely_crtfron opportunities where tean_id = 5SS and stage_id = 28616 order_*select id, crn_provider_id, nane, probability, updated_at, deleterefron stageswhere id = 28616;Sstages(] = Sstage->id;TO0У L7Thu 28 May 19:59:16ServiceTestCascadeles Orcnnworeeoeionhomwstotino oosoReview again @Service.phpaL386-468 as it has been changed on 2026-05-04. Could there be some potential issueLet me read the current state of InportStages carefully:TNOW TUETE CHUCK MEOE DEO MREUNOO POOKEO TRE DUYCTE WOy4- SoCClCoTy KhOSiTOC• git log-oneline -follou -20 app/Services/Crm/Hubspot/Service.phprestorein recreatedPR CORRentstests. 1D ceRe ort ystes toree:es are Cleanco uo fron all crys but Salestorceac snow deaszoore -staAsk anything (XOL)ceodhAdhet• OXwodeurlhime"eshires2 4 space...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88357
|
|
88356
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:59:16®8 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88356
|
|
88355
|
rapstomEV faVsco,ls ~Cooc#12121 on JY-20963-fix-In rapstomEV faVsco,ls ~Cooc#12121 on JY-20963-fix-InHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimot Cimooewhoo© ResponseNormalize.phpCSeMCPono© SyncFieidAction.phpCSWnCKealCohe wWwK© WebhookSyncBatchProceexisiinastaad10.02.23 Vasilev24.01.25 Papazovlisteners> MetadataMigrationP oedrive2.10.258.11.182.04.18Graham19.03.18 GrahamSalesforce4.05.26afeldsOpportunityMatcher4.05.20OpportunitySyncStrategyProspectSearchStrategysametite© [EMAIL] DeleteObjectsTrait.php©FieldDefinitions.php© PayloadBuilder.php© Profile.php4.[IP_ADDRESS].[IP_ADDRESS].10.254.05.264.05.264.05.26©QueryBullder.php©QueryHandler.php©Queryiterator.php4.05.26©QueryResults.php© Service.php4.05.26© SyncBatchRedisService.ptTraits© BaseCllent.php© BaseService.phpA05.282.10.2517.03.25 Hhan© Cached Crm Service Decorator© CountryCodeResolver.php© CrmActivityProviderintegrateA05.28CCnlACMiWMoh4.05.26Cermcontcurationsettinosse4.05.26©rmobiectctesower.on4.05.264.05.26© DefaultProspectSearchStrateC mallteloer.ond4.05.262.04.18GrahamFindsProspectinterface.php19.03.18 GrahamC) LvoutMansoeonoGrahamC Opportun tvActvitwlatche19.03.18 Grahameennortur wewn.CtestomedProspectCache.phpHe OrnenontCostrhSrond nhn19.03.18 Graham19.03.18 Grahamentity // View pull request (today 16:12)Window386421423427431450452454RecordSelector.php© Activity.php© Team,phpA HSJJocal (jiminny@localhost]A console (STAGING)A console (EU] x ( users (EU)class Service extends BaseService inplements170601 47 A149 X1 X33 21 A V 1707public function inportStages(Parray Stypes = null, Pstring SnissingStageNane = null): 2Stage-1708= nb_strimwidth($p['label'],start: 0,1709150).is selectable'=› $p['active'),1710*business process id' => $businessProcess->id 2? null.17111712// Stages - fetch all existing stages upfront to avoid N+1 queriessexistingstages = Sthis->config->stages()->withTrashed()->ahere('type', Stage::TYPE_OPPORTUNITY)→>oel-keyoycreprovzder20foreach (Sp['stages'] as SdealStage) ($s = ResponseNornalize: :nornalizeDealStage(SdealStage);/** Bxar 2Stoge SexistingStage */SexistingStage = SexistingStages->get(Ss['id'D);Mrestone sortooelered smons Thar ne now nctvein tuosoorif (SexistingStage?-›trashed() && $s['active')) ‹eythenastaoee res toreloI/ Ule HubspotSexistingStage: Stagelnults without restoring them)o suodatefrereatedSourco.wannsemcos/crmltuosatnhstrsmmdthScl'nahel"nb_strimwidth($s['label'],Stage::TYPE_OPPORTUNITY,→ $s['dispLayOrder'],'is_selectable' => $s['active'),onoosottrty→> $s['probability'] * 100,wdthe 59E 191),D):1715-1717-171817301731E1732-1733E17551736E1757-1738E17391740=1742—1742=1743=1744=17451746 v— 1/40—1748Thc AutoORDER BY t.nane, u.email;Se jiminny~031 49 A29 X3 X109 A VSELECT * FROM teans WHERE nane LIKE "XTourlane%'; # 187, 209, 8154.SEEiNTCONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN ' (owner)' ELSE-u.enail,sa.*,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.nc->1: on t.id = u.tean_idWHERE u.tean_id = 187 and sa.provider = 'salesforce':select * fron activities where id = 31264367:select * fron contacts where id = 6331639;select * fron accounts where id = 4156632;select * fron opportunities where id = 4843610;#updateacove300n8-4300-4"contact_id' = 6.#'stage_id' = 13273,"updated_at' = 2026-05-22 07:16:select * fron text_relays where created_at > ^2026-05-01';select * fron actvales order oy 1d descaIII !select * fron users where nane oikesubrasSELECT * FROM opportunities HHERE wuid_to_bin('04a9cfad-2c87-4453-sselect * fron teans where 10 = 55%select * fron stages where tean_id = 555;SETSTMTCONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN ' (owner)' ELSE:Mesansa.*,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.n<->1: on t.id = u.tean.idWHERE u.tean_id = 100 and sa.provider = 'hubsRes":if (SaissingStageNane a== $s['id'I) 4saissinoStaoc = Sscaockselect id, is_closed, is_won, stage_updated_at, crm_provider_id, stclose_date, forecast_category, deleted_at, created_at, renotely_crtfron opportunities where tean_id = 5SS and stage_id = 28616 order_*select id, crn_provider_id, nane, probability, updated_at, deleterefron stageswhere id = 28616;Sstages(] = Sstage->id;TO0У L7Thu 28 May 19:59:07ServiceTestCascadeles Orcnnworeeoeionhomwwtino ooooReview again @Service.phpaL386-468 as it has been changed on 2026-05-04. Could there be some potential issueLet me read the current state of inportStages carefully:TNOW TUETE CHUCK MHOE DEO MRUROO POOKEо TRy ouioTe moyd- Soeeiicoly th gia1oe• git logonietlne -tollon =zo opppoerviceserynuospoc/servace.pingrestoresin recreatedtestst gre or cas eort ierore.es" are cleaned up fron all CRMs but Salesforce.Command ciOolo0 eonette = olo-:/=/0-==oelorts420=05=0400WVeS//ihtos.conervlce.ohRunxe SkipAsk anything (XOL)* @eocaAdhet• OXwodeurlhime"eshires2 4 space...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88355
|
|
88354
|
HomeDMsActivityFilesLater..•More+Slack> 0(ah]Fi HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:59:078 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88354
|
|
88353
|
rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lHu rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoexisiinastaad10.02.23 VasilevCSWnCKealCohe wW24.01.25 Papazov© WebhookSvncBatchProcelisteners> MetadataaMiorationP oedriveEh SalesforceafeldsaOpoortunityVatchenOpportunitySyncStrategyProspectSearchStrateg.sametitealmaeoratdett ooe( DeletcObiectsTrait.phposwan ntone non© PayloadBuilder.phpc) Profile.php© QueryBuilder.phpyQueryHand ler.nhoeQuerviterator.oh© QueryResults.php© Service.php© SyncBatchRedisService.pth TraitsRaseeentono© BaseService.choCrmActivityProviderinteorateCCnlACMiWMohCermcontcurationSettnosser©rmobiectctesower.onC. DefaultProsoectSearchStrateC [EMAIL]) LvoutMansoeono8. MatchDomainByEmalllntorfacC Opportun tvActvitwlatcheeennortur wewn.Ctestomedrnenont tschd nhrHe OrnenontCostrhSrond nhn710258.11.18204.181Graham19.03.18 Graham4.05.264.05.26MAEDS4.05.264.05.262.[IP_ADDRESS].10.254.05.264.05.26405.26A05.28210.2517.03.25 Hhan405284.05.2640528A052R4052A4.05.26Cnham100249 CikamGrahan400940 Crken19.03.18 Graham19.03.18 GrahamAottywiaw oulteoueettodaw 1ReyWindow421423[PHONE]54sraveloe© RecordSelector.phgC) ACUVIY.or.PC) Team.phd# HS local [liminny@localhostA console (EU) x iii users (EU)console (STAGINGclass Service extends BaseService implements01 A7 A149 V1V33 /1 A v 170%public function importStages(Parray Stypes = null, Pstring SnissingStageNane = nulU): ?Stage1706—1708= nb_strimwidth(Sp["Label'),start: 8,is selectabile"=> Spl'active'))"busiiness anocess "id' ShusiinessPracassod27 nuiau1):Stages - fetch all existing stages upfront to avoid N+1 queriesSexistingStages = Sthis->config-›stageso-swithTrashedo->where("type'. Stage::TYPE OPPORTUNITY)→>oel-keyoycreprovzder20foreach (Spf'stages"] as SdealStage) 4Ss = ResponseNornalize::normaLizeDealStage(SdealStage)/** @xac ?Stage SexistingStage */SexistingStage = SexistingStages->get(Ss("id'))=Mhestone sortooelered smonshar ne now nctwe in tuasoorif (SexistingStage?->trashed() && $s['active')) {seyiseinastadee restoreloi/ Upsert stage (updates soft-deleted records without restoring them,Sstage = Sthis->config-›stages)->withTnashed->update0rCreate(l"crn_provider_id' => $s['id']"nane= Sthis->team->id,nhstrsmmdthScl"nahel"wdthe 59"labelh=> nb_strimwidth(Ss['label'].=> Stage::TYPE_OPPORTUNITY,=> Ss("displayOrder'),'is selectable' => Ss('active'))onoosottrty=> $s("probability' * 108,7191D):if (SnissingStageNane === $s(idi) 417161211117121719- 171%-1728=1720=1729=173017391=1732—1733E17551736E17571738= 17551746=1742=1742—1743-17451746 %Tx: AutovSo liminnyvBROER PYTnane, M.emare031 A9 A29 V3 /109 A VSELECT * FRON teans WHERE name LIKE "stounlanes: # 187, 289, 8158SEEiNTCONCAT(U.1d, CASE WHEN U.10 = t.ouner_1d THEN" (owner)" ELSEMrenasisa.*t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.soclable_idJOIN teans t 1.nc->1: on t.id = u.team_1dWHERE u.tean_id = 187 and sa.provider = 'salesfonce':select * fron activities where id = 31264367select * fron contacts where id = 6331639:select * fron accounts where id = 4156632:select * fron opportunities where id = 4843610:#Uodareacove#"stage_id' = 13273300n8-4300-4"contact_id' ="updated.at" = 2826-95-22 07:16:select * fron text relavs where created at > '2926-95-91°:select * fron actviates order oy 1d desc;select * fron users where nane oikesubrasSELECT * FROM opportunities HERE unid to_ bin('84a9cfad-2c87-4453-S|select * fron teans where 1d= 555%select * fron stages where tean_id = SSS;SETSTMTCONCAT(U.Ld, CASE WHEN U.1d = t.ouner_id THEN" (owner)" ELSElhenaonnenstil socal nccounteeJOIN users u on u.id = sa.sociable_idJOIN teans t .nc->i: on t.id = u.tean_idWHERE u.tean_id = 100 and sa.provider = 'hubspot":11111select id, is_closed, is_won, stage_updated.at, crm_provider_id, stclose date, forecast category, deleted at, created_at, remotely-crtfron opportunities where tean id = SSS and stageid = 28616 order_select id. crn-provider id, nane, probability, undated at. deletessaissinoStaoc = Sscaock— 1/40fron stageswhere id = 28616Sstagesii = Sstage»>id:TO0% L7Thu 28 May 19:59:02ServiceTestCecsdales OrcnnworeeoeionhReview again @Service.php#L386-468 as it has been changed on 2026-05-04. Could there be some potential issurLet me read the current state of inportStages carefully:Now lot me check what ths method lookcd like bilore way a = speciricoly tho giclog• git log-oneline -follow -20 app/Services/Crm/Hubspot/Service.php3+2298dSbd JY-20887 | Restore 1ostts snagees tsrestore in recreatedodress pR commentsi aro itp aecent atestoree.are Cleanco uo tron all crys but Salestorce* @eocaAdhet• OwodturThsme.eyrest4 spad...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88353
|
|
88352
|
HomeDMsActivityFilesLater..•More+Slack> 0(ah]Fi HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:59:02®8 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88352
|
|
88351
|
Project: faVsco.js, menu
rapstomCoocFV faVsco.|s ~ Project: faVsco.js, menu
rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoexisiinastaad10.02.23 VasilevCSWnCKealCohe wWwK24.01.25 Papazov© WebhookSvncBatchProcelisteners> MetadataaMiorationP oedriveEh SalesforceafeldsaopoortunitVatcherOpportunitySyncStrategyProspectSearchStrateg.sametite4.05.26elehatoon4.05.26almaeoratdett ooe( DeletcObiectsTrait.phposwan ntone non2.[IP_ADDRESS].10.25© PayloadBuilder.php4.05.26c) Profile.php4.05.26© QueryBuilder.phpyQueryHand ler.nho405.26eQuerviterator.oh© QueryResults.php© Service.php© SyncBatchRedisService.pth TraitsRaseeentono© BaseService.choA05.28210.25CrmActivityProviderinteorateCCWCMWWMoonCermcontcurationSettnosser©rmobiectctesower.onC. DefaultProsoectSearchStrateC [EMAIL]) LvoutMansoeono8. MatchDomainByEmalllntorfacC Opportun tvActvitwlatcheei lennortur tevnaCtestomiedirnenont tschd nhrHe OrnenontCostrhSrond nhn710258.11.18204.181Graham19.03.18 Graham4.05.264.05.26MAEDS17.03.25 Hhan405284.05.2640528A052R4052A4.05.26Cnhen100240 CrkanGrahan400940 Crken19.03.18 Graham19.03.18 GrahamAottywiaw oulteoueettodaw 1ReyWindowClost oias no seiveiron421423425[PHONE]54sraveloe© RecordSelector.phgC) ACUVIY.or.PC) Team.phd# HS local [liminny@localhostA console (EU) x iii users (EU)console (STAGINGclass Service extends BaseService implements01 A7 A149 V1V33 /1 A v 170%public function importStages(Parray Stypes = null, Pstring SnissingStageNane = nulU): ?Stage1706—1708= nb_strimwidth(Sp["Label'),start: 8,is selectabile"=> Spl'active'))"busiiness anocess "id' ShusiinessPracassod27 nuiau1):Stages - fetch all existing stages upfront to avoid N+1 queriesSexistingStages = Sthis->config-›stageso-withTrashed@l->where("type'. Stage::TYPE OPPORTUNITY)→>oel-keyoycreprovzder20foreach (Spf'stages"] as SdealStage) 4Ss = ResponseNormalize::normalizeDealStage(SdealStage)/** @xac ?Stage SexistingStage */SexistingStage = SexistingStages->get(Ss("id'))=Mhestone sortooelered smonshar ne now nctwe in tuasoorif (SexistingStage?->trashed() && $s['active')) {seyiseinastadee restoreloi/ Upsert stage (updates soft-deleted records without restoring them,Sstage = Sthis->config-›stages)->withTnashed->update0rCreate(l"crn_provider_id' => $s['id']"nane= Sthis->team->id,nhstrsmmdthScl"nahel"wdthe 59"labelh=> nb_strimwidth(Ss['label'].=> Stage::TYPE_OPPORTUNITY,=> Ss("displayOrder'),'is selectable' => Ss('active'))onoosottrty=> $s("probability' * 108,7191D):if (SnissingStageNane === $s(idiD -17161211117121719- 171%-1728=1720=1729=173017391=1732—1733E17551736E17571738= 17551746=1742=1742—1743-17451746 %Tx: AutovSo liminnyvBROER PYTnane, M.emare031 A9 A29 V3 /109 A VSELECT * FRON teans WHERE name LIKE "stounlanes: # 187, 289, 8158SEEiNTCONCAT(U.1d, CASE WHEN U.10 = t.ouner_1d THEN" (owner)" ELSEMrenasisa.*t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.soclable_idJOIN teans t 1.nc->1: on t.id = u.team_1dWHERE u.tean_id = 187 and sa.provider = 'salesfonce':select * fron activities where id = 31264367select * fron contacts where id = 6331639:select * fron accounts where id = 4156632:select * fron opportunities where id = 4843610:#Uodareacove#"stage_id' = 13273300n8-4300-4"contact_id' ="updated.at" = 2826-95-22 07:16:select * fron text relavs where created at > '2926-95-91°:select * fron actviates order oy 1d desc;select * fron users where nane oikesubrasSELECT * FROM opportunities HERE unid to_ bin('84a9cfad-2c87-4453-S|select * fron teans where 1d= 555%select * fron stages where tean_id = SSS;SETSTMTCONCAT(U.Ld, CASE WHEN U.1d = t.ouner_id THEN" (owner)" ELSElhenaonnenstil socal nccounteeJOIN users u on u.id = sa.sociable_idJOIN teans t .nc->i: on t.id = u.tean_idWHERE u.tean_id = 100 and sa.provider = 'hubspot":11111select id, is_closed, is_won, stage_updated.at, crm_provider_id, stclose date, forecast category, deleted at, created_at, remotely-crtfron opportunities where tean id = SSS and stageid = 28616 order_select id. crn-provider id, nane, probability, undated at. deletessaissinoStaoc = Sscaock— 1/40fron stageswhere id = 28616Sstagesii = Sstage»>id:Inu co moy 13:o0.oServiceTestCecsdales Orchancworeeoeionhomwstotino oosoReview again @Service.php#L386-468 as it has been changed on 2026-05-04. Could there be some potential issueLet me road the current state of fraartSthoes caretuiieNam lhi ma chánh vnnt thế methodloo coollike oc ore Mhy 4— SocchiCaMne cic looCommand cin0 git log —oneline -follow -20 app/Services/Crm/Hubspot/Service.phpRune CkinAat anuthientuaceodhAdhet• OwodturThsme.eyrest4 spad...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88351
|
|
88350
|
HomeDMsActivityFilesLater..•More+Slack> 0(ah]Fi HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:58:56®8 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88350
|
|
88349
|
rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lPr rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimo CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoexistinastaad10.02.23 Vasilev© synckelateoncuvnymanas 24.01.25 Papazov© WebhookSvncBatchProcelistenersGrandy19.03.18 Graham2.04.18Granay> MetadataaMicrationP oedriveEh Salesforcealeelds2.10.252.10.2520.10.21 Graham2.10.25aOpoortunityVatchenOpportunitySyncStrategyProspectSearchStrateg.Sameatitite@ Client.phpC DecorateActivity.php( DeletcObiectsTrait.phpoewanarinithooe nha16.04.25 Ivanow2.[IP_ADDRESS].04.18© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.phpeQuerviterator.oh© QueryResults.php© Service.php© SyncBatchRedisService.pin TraitsC BaseClient oho204.18710758.11.18Crsham19.03.18 Graham© CachedCrmServiceDecorato4.05.26Ansas luanai( CrmActivitvProviderinteorateCCWCMWMoron4.05.26AnEDe I4.05.26Aosnslrmopiectsdesower.oC. DefaultProsnectSearchStrateC mallteloer.ond2.10.25210263sindeProscectinternce.ono2.10.25C) LavouMansoe onoA. MatchDomainByEmallinterfac 4.05.26eennortur wewn.Ctestomedrnenont tschd nhrHe OrnenontCostrhSrond nhntylwiew oulteonaet today RayWindow•• Thu 28 May 19:58:51ServiceTestChost oees noe seiveiro40440943143343443S436439448)CMatchiet witermints chesraveto"© RecordSelector.phg© Activity.prpC) Team.phd# HS local [liminny@localhost# ConbocrhouA console (EU) x iii users (EU)# console (STAGINGCiwhoocwoneclass Service extends BaseService implements01 A7 A149 V1V33 /1 A v 170%public function importStages(?array Stypes = null, ?string SnissingStageNane = nul)): ?Stage1706— 1708ostagesa1716// We create a business process to contain the pipeline, and store all stages against it.1711Sp = ResponseNornalize::normalizePipeline(Spipeline):// Create/update business process for this pipelinesousznessrrocess & schzs->contz.psous.nessrrocesses@upoaceurcreace.'crn_provider_id' => $p['id')=> BusinessProcess:: TYPE_OPPORTUNITY,onesteluodate necond type cloneSthis->config->recordTypes(->update0rCreateCl=> Spl'id")=Sthiisosteanesid.=> nb strimwidth(Sp['Label'], start: e,Sis selectablel=> Spl'active'),busiiness.onocessid' 3 ShussinessPracessosid2% nuieuWoth1S8)/ Stages • fetch all existing stages upfront to avoid N+1 queriesexistingStages = Sthis-›config-›stageso-wwithTrashedol-swhere("type', Stage:: TYPE OPPORTUNITY)->get®->keyBy("crn provider id'):foreach (Sp[i"stages'] as SdealStage)$s = ResponseNornalize::normalizeDealStage(SdealStage)/** Bvac ?Stage SexistingStage */SexiotsineStage = SexictzinoStaoes->aet(Scf%id' 1)a12131719— 1715-1728= 172612222$1726=1729=17301173)E1732—1733=1735=1736E17571738= 1755=1742=1742=114-17451746 %Tx: AutovGo liminny vBROER PYTnane, M.emare031 A9 A29 V3 /109 A VSELECT * FROM teans WHERE nane LIKE "XTounlane%'; # 187, 209, 8158SEEiNTMrenasiCONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN " (owner)* ELSEsa.*,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.soclable_idJOIN teans t 1.nc->1: on t.id = u.team_1dWHERE u.tean_id = 187 and sa.provider = 'salesfonce':select * fron activities where id = 31264367select * fron contacts where id = 6331639:select * fron accounts where id = 4156632:select * fron opportunities where id = 4843610:#Uodareacove#"stage_id' = 13273300n8-4300-4"contact_id' ="updated at" = 2826-95-22 07:16:select * fron text relavs where created at > '2926-95-91°:select * fron actvales order oy 1d descaIII Iselect * fron users where nane tuike "subraaSELECT * FROM opportunities WHERE unid to_bin('04a9cfad-2c87-4453-$select * fron teans where 1d= 555%select * fron stages where tean_id = SSS;SETSTMTCONCAT(u.1d, CASE WHEN u.1d = t.ouner id THEN • (oaner)* ELSE •honnen siil coesasccounteeJOIN users u on u.id = sa.sociable_idahittinnetneosonettsWHERE u.tean_id = 100 and sa.provider = 'hubspot":select id, is_closed, is_won, stage_updated.at, crm_provider_id, stclose date, forecast category, deleted at, created_at, remotely-crtfron opportunities where tean id = SSS and stageid = 28616 order_select sid, ern-provider id, nane, probability, undated at, deletos:—1/40Gnon etanod1/ Pestone soft-deleted staocs that are now active Sin HubSposif (SexistingStage?->trashed( && $s['active')) ‹eyhenas moeesrescordoiwhere id = 28616CecsdaSalesforca Sarvice TworeeoeionhReview again @Service.php#L386-468 as it has been changed on 2026-05-04. Could there be some potential issueLet me read the current state of inportStages carefullyRead Service.php #L386-470Aat anthientuay Code$ AdhotsN Windeud Teamd428:30 19550 chars. 8D fne breskel UTf-яautenod...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88349
|
|
88348
|
Project: faVsco.js, menu
HomeDMsActivityFilesLater Project: faVsco.js, menu
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:58:498 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88348
|
|
88347
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimo CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoexisiinastaad10.02.23 Vasilev© synckelateoncuvnymanas 24.01.25 Papazov© WebhookSvncBatchProceGrahan?—neo aionhe19.03.18 Grahamlisteners2.04.16Granay> MetadataaMicrationP oedriveEh Salesforceafelds2.10.252.10.2520.10.21 Graham2.10.25aOpoortunityVatchenOpportunitySyncStrategyProspectSearchStrateg.sametiteê crant nho16.04.25 Ivanow2.04.18C DecorateActivity.php( DeletcObiectsTrait.phposwan ntone non2.[IP_ADDRESS]© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.phpeQuerviterator.oh© QueryResults.php© Service.php© SyncBatchRedisService.pin TraitsRaseeentono7041371075710258.11.18Crsham19.03.18 Graham4.05.26( CrmActivitvProviderinteorateCCWCMWMoron4.05.26AnEas4.05.26Aosos©rmobiectctesower.onC. DefaultProsoectSearchStrate2.10.25C mallteloer.ond21026@. FindeProsoectinterfacc.oho2.10.25C) LavouMansoe onoA. MatchDomainByEmallinterfac 4.05.26C Ooportur vActvityWatcheer ngnsei lennortur tevnaCtestomiedic) Ornenonteho nhrHe OrnenontCostrhSrond nhntylwiew oulteonaet today RayWindow40 1100% 142-• Thu 28 May 19:58:48CondootoetMeesaSalesforca Sarvice TSalesforce Deletion Al+0.Review again @Service.php#L386-468 as it has been changed on 2026-05-04. Could there be some potential issueLet me road the current ctate of fcaarSthoescaretuim40440740942142542742943143343S437439448)© RecordSelector.phgC) Team.phd#HS local [liminny@localhostA console (EU) x iii users (EU)console (STAGINGTx: AutovSo liminnyORDER BY t.name, u.ema1l031 A9 A29 V3 /109 A Vclass Service extends BaseService implements1 01A7 A149 У1/33 /1 л у 1703public function importStages(?array Stypes = null, ?string SnissingStageNane = nulU): ?Stage1706— 1703SELECT * FRON tEaNS WHERE name LIKE "Stounlanes: # 187, 289, 8158istagesa1716// We create a business process to contain the pipeline, and store all stages against it. 1711Sp = ResponseNornalize::nornalizePipeline(Spipeline);1213// Create/update business process for this pipelineSbusinessProcess = Sthis->config->businessProcesses()->update0rCreate(['crn_provider_id' => $p['id'),17191716— 1715- 1716=> BusinessProcess::TYPE_OPPORTUNITY,=1720= 172/l A record type is really a clone of the business process, used to store which recordonesteluodste necond twpe ciloneluc 172,12222172Sthis->config->recordtypes(->update0rCreateCE=> Spl'id"),= Sthiisosteane>d.l=> nb_strimwidth(Sp['label'], start: 0, width: 150),isselectahle"=> Spl'active'),business process id' => SbusinessProcess->id ?? null.1728=1729=173017391=1732—1733Stages - fetch all existing stages upfront to avoid N+1 queriesexistingStages = Sthis-›config-›stageso-wwithTrashed®#122Stage::TYPE._OPPORTUNITY)AATET—173-173→keyoy creprovzder2oforeach (Sof"stages"] as SdealStage)$s = ResponseNormalize::normalizeDealStage(SdealStage):/** Bvac ?Stage SexistingStage */SexiatineStage = SexfstsinoStaoes->aet(Scf%id:0).=174=1742—1743=174—17451746 %SEEiNTCONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN " (owner)* ELSEu.enallsa.x,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.soclable_idJOIN teans t 1.nc->1: on t.id = u.team_1dWHERE u.tean_id = 187 and sa.provider = 'salesfonce':select * fron activities where id = 31264367select * fron contacts where id = 6331639:select * fron accounts where id = 4156632:select * fron opportunities where id = 4843610:#updateacoveset "account_id' = 4156632,"contact_id' =#"stage_id' = 13273"updated.at" = 2826-95-22 07:16:select * fron text relavs where created at > '2926-95-91*:select * fron actviates order oy 1d desc;select * fron users where name Like "XSubras":SELECT * FROM opportunities WHERE uuid_to_bin('04a9cfad-2c87-4453-$select * fron teans where 1d= 555%select * fron stages where tean_id = SSS;SElSiNTCONCAT(u.id, CASE WHEN u.1d = t.ouner_id THEN ' (ouner)' ELSE:lhenasa.*,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t .nc->i: on t.id = u.tean_idWHERE u.tean_id = 100 and sa.provider = 'hubspot":select id, is_closed, is_won, stage_updated_.at, crm_provider_id, stclose date, forecast_category, deleted_at, created_at, remotely_crtfron opportunities where tean id = SSS and stage_id = 28616 orderselect sid, ern-provider id, nane, probability, undated at, deletos:— 1/40Gnon etanod11 Restone soft-deleted staoce that ane now nctive Sin HubSposif (SexistingStage?->trashed( && $s['active')) (sexistingstage->restoreo;where id = 28616ittAat anthientuay Code$ AdhotsM Wiodsud Teams39A-5 12550 chars. 8p Fon braskel UTf-яautenod...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88347
|
|
88346
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
rapstomEV favscojs ~ProjectvViewCooc#12121 on JY-20963-fx-ImgHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol Cimosewhoo© ResponseNormalize.phpCSeMCPono© SyncFieidAction.phpCSWnCKealCohe wWwK© WebhookSyncBatchProceMmacurayochyict.onexisiinastaad19.03.18 Grahamlisteners> MetadataaMicrationP oedrive19.03.18 Graham2.04.1824.04.18 Graham24.04.18 GrahamSalesforceafeldsOpportunityMatcherOpportunitySyncStrategyProspectSearchStrategysametite© Cient.php© DecorateActivity.phpG DeleteObjectsTrait.php©FieldDefinitions.php© PayloadBuilder.php© Profile.php3.10.2513.10.25 Nikolov13.10.25 NiKOIO10.09.25 man24.04.18 Graham24.04.18 Graham19.03.18 Graham13.10.25 NI2.04.1219.03.18 Graham2.04.18©QueryBullder.php©QueryHandler.php©Queryiterator.php2.10.25©QueryResults.php20.10.21 Grahan© Service.php© SyncBatchRedisService.ptTraits© BaseCllent.php16.0425 wanor© BaseService.php© CachedCrmServiceDecorator204.181Graham© CountryCodeResolver.php© CrmActivityProviderintegrate© CrmActivitvService.ohd20418Graham19.03.18 Graham204181Graham© CrmConfigurationSettingsSer©rmobiectsteso wer.ono20.10.21 Graham© DefaultProspectSearchStrate2.10.25C Emal telcerondFindsProspectinterface.php2.04.18 Graham16.04.25 IvanowC) LavouMansoe ono2.10.25® MatchDomainByEmailinterfacC Opportun tvActvitwlatche8.11.182.04.18Grahameennortur wewn.CtestomedProspectCache.phpHe OrnenontCostrhSrond nhn4057204.05.26-entity // View pull request (today 16:12)Window386 6t391393398416418420422© ServiceTest.php© DeleteObjects Trait.phpRecordSelector.phpCecsda© Team,phpA HSJJocal (jiminny@localhost)A console (STAGING]A console (EU] x E users (EU)Salesforca Sarvice Tclass Service extends BaseService inplements* Binheritdespuol1e funetion irportstages (Parnay Stypes # nurl, Pstring SaissingStageNare nUlL): 1StegeSnissingStage = null;1706| 0147 A48 X1238 21 A y 1707-1708170917101711171217131714aaill mminl ahibalatemmmmmi inamiacl mn mlem citait mm mal alai dalel immd dacle1715Sendpoint = self::getDeatsPipelinesEndpointO);1716SpipeLänesResponse = Sthis-cLient-→getInstance () ->getLient0) -srequest ( method: "GET', Sen 1747Spipelines = SpipelinesResponse->data->results;} catch (RequestException|BadRequest Sexception) (thros sexceocion"foreach (Spipelines as Spipeline) (Sstagcs = ?SbusinessProcess = Sthis->config->businessProcesses()-yupdate0rCreate(fIl A record type is really a clone of the business process, used to store which record use1739Il/ Create/update record type cloneSthis->config->recordTypes()->update0rCreate(f81743—1742'crn_provider_id' => $p['id'). |1,G→> Sthis->tean->id,"Is_selectable'→> $p['active'),"business_process_id' => SbusinessProcess-sid 77 null,D):Stages - fetch all existing stages upfront to avoid N»1 queriesbxiistinoStages a Sthis-aconfios›stages()_/Thc AutoORDER BY t.name, u.email;Se jiminny~031 49 A29 X3 X109 A VSELECT * FROM teans WHERE nane LIKE 'XTourLang%"; # 187, 209, 8150.SEEiNTCONCAT(U.1d, CASE WHEN U.id = t.ouner_id THEN(owner)' ELSEu.enail,sa.x,t.ouner_id FROM social_accounts saJOIN users u on u.id = sa.sociable_idJOIN teans t 1.nc->1: on t.id = u.tean_idWHERE U.tean_id = 187 and sa.provider = 'salesfonce":select x fron activities nhere id = 31264367;select x fron contacts where id = 6331639:select * fron accounts where id = 4156632;"account_id' = 4156632, 'contact_id' = 6:updated_at* = 2026-05-22 07:16:select * fron text., relays where created_at > 12026-85-81';select * fron activities order by id desc;SELECT * FROM opportunities WHERE wuid_to_bin('04a9cfad-2c87-4453-$selectnhere id = 555:select * fron stages where tean_id = 555;SELECTCONCAT(U.1d, CASE WHEN U.id = t.omner_id THEN • (onner)' ELSE_sa.*,t.ouner_id FROM social_accounts saJOIN teans t 1.n<->1: on t.id = u.tean_icNHERE v.tean_id = 100 and sa.provider = 'hubspot' :select id, is_closed, is_uon, stage_updated_at, orn.provider_id, stclose_date, forecast_category, deleted_at, created_at, renotely_crtfron opportunities28616 order]select id, crn_provider_idupdated_at, deleterfron stagesrt anuthientwaCode$ AdaptiveSalesforce Deletion Ro$0 M•• Thu 28 May 19:58:45U ServiceTest ~+0.Review again @Service.phpaL386-468 as it has been changed on 2026-05-04. Could there be some potential issueW Windsurf Teams386.5 (3552 chars, 82 line breaks) UTF-8 P 4 space...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88346
|
|
88345
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
PhpStormproidetViewNeweNNCCoocKetucioRurWindowFV faVsco.s ~#12121 on JY-20963-fx-inCSamviceTeston= custom.logconeola ioeoiHubspotClientinterface.phwalenaedvityCimbalo.ong© CrmActivityService.phdaewemwwocorotor.or(©) ProspectCache.phg© HubspotTokenManager.pt© PayloadBuilder.phpACUViy.onC) Team.phdoKimol Cimosewhoowwowohuryoyncltaione Hubsooti netlodecreated encondyo pens eoworeyon© PayloadBulder.phgResponseNormalize.pho©crmentityRepository.ohgCSeMCPono© SyncFieldAction.ohoexismesa04sCSWnCKealCohe wWwK1178910.02 22 Vacila© WebhookSvncBatchProceclass Service extends BaseService impl =01 47 A149 X1 233 21 A V 1718>D IntegrationAodlisteners> MetadataaMicration20.07.28 toni-FminnyOAKOnie thinneoAkonie thinnMeoAkonieminn325tabelistring,velluetrhel*SooelonsP oedrive"whrons wnarccoelonSalesforcealrelds20.07.23 toni-fminnsaTo poortunitVatchen>b OpportunitySyncStrategnProsoectSaarchstrateo330331332333334 61339336337383* Ecetuca Ricidbata!nubiaictunctaioninoontpicktistValuesRield Stield,array Soptions = "id" a>•• "label' => 1, Avalue' => *e11lD: array (..171317141715171617171718-1719172017221723Do liminny v031 49 A29 V 3 У 109 A 1© Csent.oheC DecorateActivity.php( DeletcObiectsTrait.phpoewanarinithooe nha© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.php© Queryiterator.php© QueryResults.php© Service.php© SyncBatchRedisService.pin TraitsC BaseClient oho© BaseService.choecachedermserviced ecorato©CountryCodeResolver.ohdCrmActivityProviderinteorate© CrmActivitvService.ohd© CrmConficurationSettinasSe©rmobiectsteso wer.onoC.DefaultProsoectSearchStrat.C [EMAIL]) LavouMansoe ono8. MatchDomainByEmalllntorfacC Opportun tvActvitwlatcheeennortur wewn.Ctestomedrnenont tschd nhrHe OrnenontCostrhSrond nhnSELECT * FROM teans NHERE nane LIKESELECT20.0723 tonis minny20.07.23 toni-minny30.0974 Panazov20.07.23 tonie minny19.03.18 Graham1002 18CrohanOnK 1e19.03.18 GrahanDAIAE OnasAl40.02.18 Graham2.04.1824.04.18 GrahamMAOAR Granam3.10.253.10.2513.10.25 Nikolov13.1025 Nikolo24.04.18 GrahamMoeaGrahan19.08.18 Graham204.18elokasGrahan17.03.25 ilian79/0389392393394CrchonAottywiaw oulteoueettodaw 1Reyv.emaslss.*t.ouner_id FRON social_accounts sahineane tantdre eocahiJOIN teans t 1.n<->1: on t.id = u.tean_icTHERE v.team_id = 187 and sa-provider = 'salestonce':select * from activities where id = 31264367select * from contacts where id = 6331639:colont & faan sacdunte shons id e llicaaza.select * from opportunities where id = 4843610:# update"antivitioc' cot'account id' = 4156632, 'contact id' = 6331639opportunity. id' =4E# 'stage id' = 13273"activities'. "updated at' = 2826-85-22 87:16:17 where"id' = 310КARA9)select * from text relays where created at > '2826-85-81*:GocnadoyeExtract Surround Ipublic function importStages(?array Stypes = null, ?string Snissingst 172)select * fron usens where nane Uike 'gSubrak':SmissingStage = null:SELECT * FROM oppontunitsies WHERE wuid to bint:04a9cfad-2087-4453-9e72-28aeb78ccf8d') = uuid:select * fron teans nhere $id = 555,try"I/ Use the HubSpot API client instead of the SDK ernPipelines( 173)Sendpoint = self::getDealsPipeZinesEndpointo:SpipelínesResponse = Sthis->client-›getInstance()->getClient(),175)Spipelínes = SpipelinesResponse->data->resultscatch (Request Bxceattion|BadRequest Sexcentzion) {nhon serecoreion"select * #rom sages nheretean id = 555,SELECTCONCAT(U,Ád. CASE NHEN u.Sid = townen id THEM * (onner) * ELSE ** END) AS usen $dlu.eesCh.*t.ouner_id FROM social_accounts saJoMusens u on u.sid e ch.socinhleoJOIN teams t 1.n<->1: on t.id = u.tean_icTHEPF uatean 51d = 100 and sa,provider = "hubspor"?foreach (SoineLines as Spineline) !staoesscolont d e aincodemon etano lundntod nt em nnaundon Al ctano id noahshiltyclose date, forecast category, deleted at, created at, remotely created at, updated aE17ts vv select id, em providen id, nane, probabtlity, updated at, celeted otfros spertrites there toan.38 5 end tape 18 20536 orser by vpfted. t tos unt 105I/ Greate/uodate business nrocess for this ninelineEhAn ctanodSbusinessProcess = Sthis->config->businessProcesses()->updated 1748hono 4al- 2061hcrn_provider_id' => Sp['id').Sthisosteamesd.=> nb strimwidth(So('Label'], start: 8, -— PucknoGePRANnGGII TVDE NOQnOTIMTTyAP1AI011inu cowoy 13ioo.ServiceTestCecsdaSalesforca Sarvice TworeeoeionhReview again @Service.php#L386-468 as it has been changed on 2026-05-04. Could there be some potential issueAat anthientuay Code$ AdhotsM Wiodsud Teams39A-5 12550 chars. 8p Fon braskel UTE-R AAenod...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88345
|
|
88344
|
HomeDMsActivityFilesLater..•More+Slack> 0(ah]Fi HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:58:428 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88344
|
|
88343
|
PhpStormproidetViewNeweNNCCoocKetucioRurWindowFV f PhpStormproidetViewNeweNNCCoocKetucioRurWindowFV faVsco.|s ~#12121 on JY-20963-fx-inCSamcetescon=custom.logHubspotClientinterface.phwalcnaedviycimDalo.ong© CrmActivityService.phdaewemwwocorotor.or(©) ProspectCache.phg© HubspotTokenManager.pt© PayloadBuilder.phpACUViy.onC) Team.phdHuospouservice.phooKimot Cimooewhoouoowonur yoynctaionResponseNormalize.phoCSeMCPono© SyncFieldAction.ohowxismo4o4sO Hubsoot netllodectreated enoondyopens yooweleeon© PayloadBulder.phgCSWnCKealCohe wWwK© WebhookSvncBatchProce>D IntegrationAodlisteners> MetadataaMicrationP oedriveSalesforcealreldsaTo poortunitVatchen>b OpportunitySyncStrategnProsoec SaarchStrateo© Csent.oheC DecorateActivity.php( DeletcObiectsTrait.phposwan ntone non© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.php© Queryiterator.php© QueryResults.php© Service.php© SyncBatchRedisService.pin TraitsC BaseClient oho© BaseService.choecachedermserviced ecorato©CountryCodeResolver.ohdCrmActivityProviderinteorate© CrmActivitvService.ohd© CrmConficurationSettinasSe©rmobiectsteso wer.onoC.DefaultProsoectSearchStrat.C [EMAIL]) LavouMansoe ono8. MatchDomainByEmalllntorfacC Opportun tvActvitwlatcheeennortur wewn.Ctestomedrnenont tschd nhrHe OrnenontCostrhSrond nhn©crmentityRepository.ohg10.02 22 Vacila20.07.28 toni-FminnyOAKOnie thinneoAkonie thinnMeoAkonieminn20.07.23 toni-fminns20.0723 tonis minny20.07.23 toni-minny30.0974 Panazov20.07.23 tonie minny19.03.18 Graham1002 18CrohanOnK 1e19.03.18 GrahanDAIAE OnasAl40.02.18 Graham2.04.1824.04.18 GrahamMAOAR Granam3.10.253.10.2513.10.25 Nikolos13.1025 Nikolo24.04.18 GrahamMoeaGrahan19.08.18 Graham204.18elokasGrahan17.03.25 ilianclass Service extends BaseService impl =01 47 A149 X1 233 21 A V 1718117891-1711325S /0389392393394CrchonAottylwiew outrenuaet todayRaytabelistring,velluetrhel*Sooelons"whrons wnarccoelon* Ecetuca Rieidbata!nuhiaictunctaion sinoortpiicktietValuesd Rield Stield,array Soptions = "id" a>•• "label' => 1, Avalue' => *e11lD: array (..1722GocnadoyeExtract Surround Ipublic function inportStages(?array Stypes = null, ?string Snissingst 172)SmissingStage = null:i use the Hubsoot APT eltent instead of the S0K eruptoe11ne 175gSendpoint = self::getDealsPipeZinesEndpointo:SpipelínesResponse = Sthis->client-›getInstance()->getClient(),175)Spipelínes = SpipelinesResponse->data->resultscatch (Request Bxceattion|BadRequest Sexcentzion) {nhon serecoreion"MIRINforeach (SoineLines as Spioeline) {staoessI/ Greate/uodate business nrocess for this ninelineSbusinessProcess = Sthis->config->businessProcesses()->updated 1748crn_provider_id' => Sp['id').Sthisosteamesd.coneolaioeoiconsolA ISTAGiNG"Do liminny vSELECTv.emaslSS.*t.ouner_id FRON social_accounts satineane tanttreeoc.ahihJOIN teans t 1.n<->1: on t.id = u.tean_icTHERE v.team_id = 187 and sa-provider = 'salestonce':select * from activities where id = 31264367select * from contacts where id = 6331639:colont & faan sacdunte shons id e llicaaza.select * from opportunities where id = 4843610:# update"antivitioc' cot'account id' = 4156632, 'contact id' = 6331639opportunity. id' =4E# 'stage id' = 13273"activities'. "updated at' = 2826-85-22 87:16:17 where"id' = 310КARA9)select * fron usens where nane Uike 'gSubrak':SELECT * FROM oppontunitsies WHERE wuid to bin(:04a9cfad-2087-4453-9e72-28aeb78ccf8d') = uuid:select * fron teans nhere $id = 555,select * fron stages nhere tean $d = 555,SELECTCONCAT(U,Ád. CASE NHEN u.Sid = townen id THEM * (onner)* ELSE ** END) AS usen $dlu.eesCh.*t.ouner_id FROM social_accounts saJoMusens u on u.sid e ch.socinhleoJOIN teams t 1.n<->1: on t.id = u.tean_icTHEPF uatean 51d = 100 and sa,provider = "hubspor"?Trs eoper ortes tre ton .18 5 n tape ld 23 orter by yate, t tae lunit 10select id, crm provider id, nane, probability, updated at, deleted atfron stageshono 4al- 2061h11IECecsdaSalesforca Sarvice T40 1Salesforce Deletion Alnves dohtine OooondThu 28 May 19:58:39ServiceTestRevicw again @Servicc.phpsL386-468 as it has been changed on 2026-05-04. Could there be some potential issucrt anuthientway Code$ AdhotsM Wiodsud Teams39A-5 12550 chars. 8p Fon braskel UTf-яaudenod...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88343
|
|
88342
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:58:39®8 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88342
|
|
88341
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'ac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88341
|
|
88340
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:58:308 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88340
|
|
88339
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'ac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88339
|
|
88338
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78• Thu 28 May 19:58:228 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88338
|
|
88337
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'ac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88337
|
|
88336
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'ac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88336
|
|
88335
|
rapstomViewCoocKetucioWindowFV faVsco.|s ~#12121 o rapstomViewCoocKetucioWindowFV faVsco.|s ~#12121 on JY-20963-fx-lCSamviceTestonDeleteObjectsTrait.phgHubspotClientinterface.phwalcnaedviycimDalo.ongy uimaeumyociwiceonaewemwwocorotor.or(©) ProspectCache.phg© HubspotTokenManager.pt© PayloadBuilder.phpACUViy.onoKimol Cimosewhoowwowohuryoyncltaione Hubsooti netlodecreated encondyo pens eoworeyon© PayloadBulder.phgResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoxsTo404X5 CALYAZ console (EU] x (il users (EUCSWnCKealCohe wWwK© WebhookSvncBatchProce>D IntegrationAodlisteners> MetadataaMiorationP oedriveEh SalesforceafeldsaOpoortunityVatchena OpportunitySyncStrategyProsoectSaarchstrateosametite© Csent.oheC DecorateActivity.php( DeletcObiectsTrait.phpoewanarinithooe nha© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.php© Queryiterator.php© QueryResults.php(©) Service.php© SyncBatchRedisService.pin TraitsRaseeentono© BaseService.choecachedermserviced ecoratoCrmActivityProviderinteorateCCnlACMiWMohCermconticurationSettinass©rmobiectsteso wer.onoC.DefaultProsoectSearchStrat.C [EMAIL]) LavouMansoe ono8. MatchDomainByEmalllntorfacC Opportun tvActvitwlatcheeennortur wewn.Ctestomedrnenont tschd nhrHe OrnenontCostrhSrond nhn10.02 22 Vacila20.07.28 toni-FminnyOAKOnie thinneoAkonie thinnMeoAkonieminn20.07.23 toni-fminns20.0723 tonis minny20.07.23 toni-minny30.0974 Panazov20.07.23 tonie minny19.03.18 Graham1002 18CrohanOnK 1e19.03.18 GrahamDAIAE OnasAl40.02.18 Graham2.04.1824.04.18 GrahamMAOAR Granam3.10.253.10.2513.10.25 Nikolov13.1025 Nikolo24.04.18 GrahamMoeaGrahan19.08.18 Graham204.18elokasGrahan17.03.25 ilian117891class Service extends BaseService impl =01 47 A149 X1 233 21 A V 1718325327329336331334 @339336337382383389386 @389392393394CrchonCtty lwew ou teoueet today lhaytabelistring,velluetrhel*Sooelons17121"whrons wnarccoelon* Ecetucn Ricidbatal17171718-1719nuhtictunctaion sinoortPicktictValues1720Rield Stield,array Soptions = "id" a>•• "label' => 1, Avalue' => *e11lВ ИHII-1721722D: array (..* Sinhevitdocpublic function inportStages(?array Stypes = null, ?string Snissings*1728SmissingStage = null:try dI/ Use the HubSpot API client instead of the SDK crnPipelines(1733Sendpoint = self::getDealsPipelinesEndpointoASospelinesResponse = Sthis-›client->getInstance()-›getClient() 173)Spipelínes = SpipelinesResponse->data->resultscatch (Request Bxceattion|BadRequest Sexcentzion) «hron Sexecolelontforeach (SoineLines as SpineLine) !1743// Ne create a business process to contain the pipeline, and 7 1744$p = ResponseNormalize: :normalizePipeline(Spipeline):// Goeate/uodate business process for this ninelineSbusinessProcess = Sthis->config->businessProcesses()->update0 1748"teansdSthsisosteamesd.=> nb strimwidth(Sof 'Label'], start: 8, =— PICKnOGGPRAAOGQIITVOG NOOnOTIMITyDo liminny v031 49 A29 У 3 У 109 A 18158, salesforce-adningtourlane.sSELECTCONCAT(u.id, CASE WHEN u.1d = t.owner id THEN • (oaner)* ELSE ** END) AS uSen 3av.emaslsa.*,t.ouner_id FRON social_accounts saJOIN users u on u.id = sa.sociable.ioJOIN teans t 1.n<->1: on t.id = u.tean_icTHERE v.team_id = 187 and sa-provider = 'salestonce':select * from activities where id = 31264367select * from contacts where id = 6331639:colont & ran sacdunte nhono id = lickAz).select * from opportunities where id = 4843610:# update"antivitioc' cot'account id' = 4156632, 'contact id' = 6331639"onnontuntty id'nis# 'stage id' = 13273"activities'. "updated at' = 2826-85-22 87:16:17 where'id' = 31264367)select * from text relays where created at > 2826-85-81':select * fron usens where nane Uike 'gSubrak':SELECT * FROM oppontunitsies WHERE wuid to bint:04a9cfad-2087-4453-9e72-28aeb78ccf8d') = uuid:select * fron teans nhere $id = 555,select * from stages nheretean id = 555,SELECTCONCAT(U,Ád. CASE NHEN u.Sid = townen id THEM * (onner)* ELSE ** END) AS usen $dlu.eesCh.*t.ouner_id FROM social_accounts saJoMusens u on u.sid e ch.socinhleoJOIN teams t 1.n<->1: on t.id = u.tean_icTHERE u.tean51d = 100 and sa,provider = "hubspor":colont d e Aincod emon etano lundntod ot one nnaundon dl ctand id noahshiltyclose date, forecast category, deleted at, created at, remotely created at, updated aTrs eoper ortes tre ton .18 5 n tape ld 23 orter by yate, t tae lunt 10select id, crm provider id, nane, probability, updated at, deleted atEhAn ctanodhond da. 20414TO0У L7Thu 28 May 19:58:06ServiceTest+0.meesdales Orcnnnves dohtine Oooondreturned IsClosed": true stacs with probability o for lost stages, or lt there was a manual overndccan you check what the probabilty of stage 20616 was in the DB before May 4? Run this query = it'l tell us the exact historyO soselect id, cmn_provider_1d, nane, probability, updated at, deleted_attron stageswhere sezoyAnd also - do you have anry sudit log or history table for stages? Something like:D soselect * fron stage_histories where stage_id = 20616 order by created at desc linit 20)select * fron activity_log wbere subject_type Like "*Stagek" and subject_id = 20616 order by created_at desc;wthottthathictor twntewetthadotaconchue.wotvtalleteCanthamed HooeusStaac /0611012osed0na1n90 onb8rryuinhiminnvis obror the entire ocriodiromne Nov19.02sou Kumoor hrouch ad casiaMayz2oFvidlence.78616 durina that window has fis won -0 = inclugino once whoee woantednt ie he rocont ne May 26695728), May 20 ( 6955703 6554495May 15 69562587 May 4 7188578• Record 6054405 h90 3ful syng on May 19 uodatedlnt-sthoe uodated atidst wrote tewainao chaoe was chill orobsblity 0 on May 19• Record 6956728 was synced May 26 → still is von = 0 → stage still probability 0 on May 26Your manual sync on May 28 tixrd 494843198845 - stage now orobability 10Satha dhad waedt mohsh"dnnt comati ma hotudan Mov 26 and Mav 28Why did stage 20616 have probability 07Mubscoteinore oscd on ioconv rcorcecienVonainaca orhis cusiome tncy mamcd rotAVon (cooine Sioncoiw aBur,ubsootinternadefault for closedlost is probability 0 (it's literally the "Closed Lost" built-in stage ID in HubSpot). At some point their HubSpot admin customised theThe stage.uodated.at = 2026-45-44 you saw was not when probability chanaed to 100 — it was a ditferent field vodate (Label, sequenco, tsuselectable)Tha concaauanedThere are -7+ opportunities for this team in staoe.id = 20616 with iswon = 8 that need to be corrected. You can tix them a. withSET 15 won = 1, is closed = 1WHERE team 1d = 555 AND staoe 1d = 28616 AND 15 wOn = 0 AND deleted at TS NULL:ceodhAdhetXModtur Tatme "hkekwhirest4 spad...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88335
|
|
88334
|
HomeDMsActivityFilesLater..•More+Slack> 0(ah]Fi HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:58:078 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88334
|
|
88333
|
rapstomProinet vViewNeweNNCCoocKelucioRurWindowFV rapstomProinet vViewNeweNNCCoocKelucioRurWindowFV faVsco.|s ~#12121 on JY-20963-fx-l© Service Test.php© HubspotClientinterface.phwalennedwilycmbdlo.orgy uimaeumyociwiceonaewemwwocorotor.or© ProspectCache.phe© HubspotTokenManager.pt© PayloadBuilder.phpACUViy.onZ console (EU) * l users (EU)o Kimol Cmoccwnoooonor yoync taionResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoxsTo404e Hubsooti netlodecreated encondyo pens eoworeyon© PayloadBulder.phOojiminny031 49 A29 V 3 У 109 A 1CSWnCKealCohe wWwK© WebhookSvncBatchProce>D IntegrationAod10.02 22 Vacilaclass Service extends BaseService impl= 01 A7 A149 V1 V33 /1 A v 121ouoexe tunceion aapomorogestrorroy soypos a nuet, totring phhosengez1alistenersforeach (Spinelines as Spipeline) "Sstages =SELECTv.enai?,E. omner. 3d FROM soctal, ecounts sahineane tantdre eocahi> Metadata204412aMicration19.03,18 GrahamP oedrive04412Eh SalesforceafeldsaOpoortunityVatchenS0 e espene 0 rus12e3 ereati to contain the pipline, and 1775Sp = ResponseNormalize::normalizePipeline(Spipeline);THERE v.team_id = 187 and sa-provider = 'salestonce':coleame harwloe thond=xnhkhcolorme canthate nhonda= kxxkzoOpportunitySyncStrategyProsoectSaarchstrateo20x107 Grahamll create/update business process for this pipelineSbusinessProcess = Sthis->consio-sbusinessProcesses()-supdate0 1719sametite@ Client.phpC DecorateActivity.php( DeletcObiectsTrait.phposwan ntone non489select * from accounts where id = 4156632;select * from opportunities where id = 4843610;# updateactivities' set'account id' = 4156632, 'contact id' = 6331639"onnontuntty id'nis# 'stage_id' = 13273,"activities'. "updated at' = 2826-85-22 87:16:17 where*1d* = 31264367)"2.04.18select * from text_relays where created_at > *2826-05-01';© PayloadBuilder.phpc) Profile.phpe QueryBulderonp© QueryHandler.php© Queryiterator.php© QueryResults.php© Service.php© SyncBatchRedisService.pin TraitsRaseeentono© BaseService.choAMANdACIMSAM60D660Grahamolnzteershhm20418413415select * from users where nane Like "%Subra%';20.10.21 Graham2.10.252.04.18Graham417210 25210.258.11.182.04.18Graham19.03,18 Graham421SELECT * FROM opportunities WHERE Uuid_to_bin(*04a9cfad-2087-4453-9e72-20aeb78ccf8d*) = uuid;select * fron teans nhere $id = 555,select * fron stages nhereSELECTtean id = 555,CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (ouner)' ELSE "* END) AS user_id423( CrmActivitvProviderinteorate426CCWCMWMoron4.05.26©rmobiectctesower.on40523C. DefaultProsoectSearchStrate4.05.26C mallteloer.ond40523@. FindeProsoectinterfacc.oho428es = Sthis->confio->stageso)-yithTrashed@-sahene("type" Stage:-TYPE OPPORTUNITY)-1740sa.*t.ouner_id FROM social_accounts saAOTN usens u on uaiid = sausociable soJOIN teans t (1.n<-›1: on t.id = U.tean_idTHERE u.tean 1d = 188 and sa,providen = "hubspot"select id, is closed, is won, stage updated at, crn provider id, stage id, probabilityclose_date, forecast_category, deleted_at, created_at, renotely_created_at, updated_at-keybycom orouiiderdC) LavouMansoe ono8. MatchDomainByEmalllntorfacforeach (Sof"ataoes"l as SdealStaoe)Se = ResponseNonnslize:snonna&izeDealStaae(SdeslStaoe):select id, crm provider id, nane, probability, updated at, deleted atfron stageshond da. 20414C Opportun tvActvitylatcheereennortur wewn.Ctestomed/** evar ?Stage SexistingStage */CoysctinoSthns=@oysctsonStonoc.soot(kefisdrl)-rnenont tschd nhrHe OrnenontCostrhSrond nhn# Pectons cottodolotod etonoe that nno now nativo in HutTO0У L7Inu cowoy 13ioo.U"ServiceTestmeesdales Orcnnnves dohtine Oooond+0.returned IsClosed": true staacs with probability o for lost stages, or li there was a manual overndccan you check what the probabilty of stage 20616 was in the DB before May 4? Run this query = it' tell us the exact historyO soselect id, cmn_provider_1d, nane, probability, updated at, deleted_atTron stageswhere sezoyAnd also - do you have anry sudit log or history table for stages? Something like:D soselect * fron stage_histories where stage_id = 20616 order by created at desc linit 20)select * fron activity_log wbere subject_type Like "*Stagek" and subject_id = 20616 order by created_at desc;wthotthathietoraatewastthadotaconclue.wolv talleteCanthames HoousStaac /0611012osed0na1n90 onb8rryuinhiminnvis obror the entire ocriodiromne Nov19.02sou Kumoor hrouch ad casiaMayz2oEvicenca-78616 durina that window hae Bis won - 0 = incluc no onc whoge manntcd nt he rocont ne May 26 /6951728), May 20 ( 6955703 6554495 M94 15 6956258 May47188578• Record 6054405 h90 3ful syng on May 19 uodatedlnt-sthoe uodated atidst wrote tewainao chaoe was chill orobsblity 0 on May 19• Record 6956728 was synced May 26 → still is von = 0 → stage still probability 0 on May 26Your manual sync on May 28 tixro 494843190845 - stage now orobability 100Satha dhas waeat motsddnnt comat ma hatudan Mow 26 and May 28Why did stage 20616 have probability 07Mubscoteinore oscd on ioconv rcorcecienVonainaca orhis cusiome tncy mamcd rotAVon (cooine Sioncoiw aBur,ubsootinternadefault for closedlost is probability 0 (it's literally the "Closed Lost" built-in stage ID in HubSpot). At some point their HubSpot admin customised theThe stage.uodated.at = 2026-45-44 you saw was not when probability chanaed to 100 — it was a ditferent field vodate (Label, sequenco, tsu selectable)Tha concaauanedThere are -7+ opportunities for this team in staoe.id = 20616 with iswon = 8 that need to be corrected. You can tix them a. withSET 15 won = 1, is closed = 1WHERE team 1d = 555 AND staoe 1d = 28616 AND 15 wOn = 0 AND deleted at TS NULLEceodhAdhetCtty lwew ou teoueet today lhayNtwodtur Taame"hiehhiest4 spad...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88333
|
|
88332
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
PhpStormViewNeweNNCCoocKetucioRurWindowFV faVsco.|s ~#12121 on JY-20963-fx-inproidetCSamviceTestonHubspotClientinterface.phwalennedwilycmbdlo.orgy uimaeumyociwiceonaewemwwocorotor.or© ProspectCache.phe© HubspotTokenManager.pt© PayloadBuilder.phpACUViy.ono Kimol Cmoccwnoooonor yoync taionResponseNormalize.phoe Hubsooti netlodecreated encondyo pens eoworeyonCSeMCPono© SyncFieldAction.ohoexismesa04sCSWnCKealCohe wWwK© WebhookSvncBatchProce>D IntegrationAod10.02 22 Vacilalisteners> MetadataaMioration4.05.204.05.264.05.2644443P oedriveEh Salesforce405.70405.20aleelds405.20aTo poortunitVatchena OpportunitySyncStrategyProspectSearchStrateg.sametite@ Client.phpC DecorateActivity.php( DeletcObiectsTrait.phpoewanarinithooe nhaJ. E4494.05.2645140528© PayloadBuilder.phpc) Profile.phpe QueryBulderonp© QueryHandler.phpeQuerviterator.oh© QueryResults.php© Service.php© SyncBatchRedisService.pin TraitsRaseeentono© BaseService.choe enchedcrmsarvice decoralo453A05.28athisos eane»> nb_ strimwidth(Ss("Label').nhstemdthieis"ahetetyheStage::TYPE_OPPORTUNITY,a Ssf'disolavioder'1."is_selectable' => $s['active'],"probability"→> Ssl'probabiuity') * 100,20418Graham190218 Graham457204181Grahan1f (SnissingStageNane a== Ss['id'I) (SmissingStage = Sstage;20418Grahan190218 Graban45920418Graham100240 Crkan461Sstagesi = Sstage->id:100240 Crken463( CrmActivitvProviderinteorateCCWCMWMoron2.04.1819.03.18 Graham19.03.18 Graham19.03.18 Graham19.03.18 Graham19.03.18 Graham25.06.18 GrahamSbusinessProcess->stagesO)->sync(Sstages)46Srerurn saissingstageExtract Surround I5 :©rmobiectsteso wer.onoC. DefaultProsoectSearchStrate75.0618 Graham25.06.18 Graham* Oinher tdocC mallteloer.ond@. FindeProsoectinterfacc.oho408.24 Vas ewC) LavouMansoe ono2.04,188). MatchDomainByEmalllntorfac3RGrahamC Opportun tvActvitwlatcheee25.06.18 Grahan473 @t )oublic functsion syncûrganizatsionO= voidf...}487CinhendoeSewpanazoueennortur wewn.Ctestomed*Cuhhone ihe syceotonrnenont tschd nhrHe OrnenontCostrhSrond nhn25.09.19 Graham401.0toubite functton evacPoneileefalleen SueenToSeanch e oullle 2pooealeletywiew oul teoneet todaw Rayconsole (EU) *a users (EU)172l1739-172F174=1745Oo liminny v031 49 A29 V 3 У 109 A 1SELECTCONCAT(U.1d, CASE THEN U.id = t.owner_id THEN" (ouner)' ELSE" END) AS user_idt.omner_id FROM social_accounts sahineane tantdre eocahiJOIN teams t 1..nc-THERE v.team_id = 187 and sa-provider = 'salestonce':coleame harwloe thond=xnhkhselect * from contacts where id = 6331639select * from accounts where id = 4156632;select * from opportunities where id = 4843610;# update# 'stage_id' = 13273,"contont ia' - ArtiAto"onnontuntty in'nis"activities'. "updated at' = 2826-85-22 87:16:17 where*1d* = 31264367)"select * from text_relays where created_at > '2026-85-01':nane Tike "gSubrak':SELECT * FROM opportunities WHERE vuid_to_bin('04a9cfad-2c87-4453-9e72-28aeb78ccf8d*) = vuid;_tean 3d = 555,CONCAT(U,Ád. CASE NHEN u.Sid = townen id THEM * (onner)* ELSE ** END) AS usen 5dlsa.*,1.n<->1: on t.id = u.tean_idTHERE uatean id = 100 andsa.provider ='hubspot"select id, is closed, is won, stage updated at, crn provider id, stage id, probabilityclose_date, forecast_category, deleted_at, created_at, renotely_created_at, updated_atselect id, crm provider id, nane,probability, updated at, deleted atfron stageshond da. 20414TO0У L7inu co woy t3roo.ServiceTestmeesdales Orcnnnves dohtine Oooond+0.returned IsClosed": true staacs with probability o for lost stages, or li there was a manual overndccan you check what the probabilty of stage 20616 was in the DB before May 4? Run this query = it' tell us the exact historyO soselect id, cmn_provider_1d, nane, probability, updated at, deleted_attron stageswhere sezoyAnd also - do you have anry sudit log or history table for stages? Something like:D soselect * fron stage_histories where stage_id = 20616 order by created at desc linit 20)select * fron activity_log wbere subject_type Like "*Stagek" and subject_id = 20616 order by created_at desc;wthotthathietoraatewastthadotaconclue.wolv talleteCanthames HoousStaac /0611012osed0na1n90 onb8rryuinhiminnvis obror the entire ocriodiromne Nov19.02sou Kumoor hrouch ad casiaMayz2oEvicenca-78616 durina that window hae Bis won - 0 = incluc no onc whoge manntcd nt he rocont ne May 26 /6951728), May 20 ( 6955703 6554495 M94 15 6956258 May47188578• Record 6054405 h90 3ful syng on May 19 uodatedlnt-sthoe uodated atidst wrote tewainao chaoe was chill orobsblity 0 on May 19• Record 6956728 was synced May 26 → still is von = 0 → stage still probability 0 on May 26Your manual sunc on May 28 timro 494813190845 - stage now orobabilityorSatha dhad waetmohs,Wonnt comat ma hotudan Mav 26 and Mav 28Why did stage 20616 have probability 07Mubscoteinore oscd on ioconv rcorcecienVonainaca orhis cusiome tncy mamcd rotAVon (cooine Sioncoiw aBur,ubsootinternadefault for closedlost is probability 0 (it's literally the "Closed Lost" built-in stage ID in HubSpot). At some point their HubSpot admin customised theThe stage.uodated.at = 2026-45-44 you saw was not when probability chanaed to 100 — it was a ditferent field vodate (Label, sequenco, tsu selectable)atetha nrohsh wmw haama hin liminnv wor racontlTha concaauanedThere are -7+ opportunities for this team in staoe.id = 20616 with iswon = 8 that need to be corrected. You can tix them a. withSET 15 won = 1, is closed = 1WHERE team 1d = 555 AND staoe 1d = 28616 AND 15 wOn = 0 AND deleted at TS NULLEceodhAdhetPhkty chure iytos "arkel hirest4 spad...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88332
|
|
88331
|
HomeDMsActivityFilesLater..•More+Slack> 0(ah]Fi HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:58:02®8 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88331
|
|
88330
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'ac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88330
|
|
88329
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:57:58®8 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88329
|
|
88328
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'ac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88328
|
|
88327
|
HomeDMsActivityFilesLater..•More+Slack> 0(ah]Fi HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:57:528 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88327
|
|
88326
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'ac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88326
|
|
88325
|
rapstomViewCoocFV faVsco.|s ~#12121 on JY-20963-fx rapstomViewCoocFV faVsco.|s ~#12121 on JY-20963-fx-lHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoCSWnCKealCohe wWwK© WebhookSvncBatchProcelisteners> MetadataaMicrationP oedriveEh SalesforceafeldsaOpoortunityVatchenOpportunitySyncStrategyProspectSearchStrateg.Sameatititeg Client.pheC DecorateActivity.php( DeletcObiectsTrait.phpoewanarinithooe nha© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.phpeQuerviterator.oh© QueryResults.php© Service.php© SyncBatchRedisService.pth TraitsC BaseClient oho© BaseService.cho© CachedCrmServiceDecoratoCrmActivityProviderinteorateCCnlACMiWMohCermconticurationSettinass©rmobiectsteso wer.onoC. DefaultProsoectSearchStrateC mallteloer.ond@. FindeProsoectinterfacc.ohoexismes404s10.02 22 Vacila4.05.204.05.26405.20405.70405.20405.20405.2640528405.2820418Graham190218 Graham204181Grahan20418Grahan190218 Graban20418Grahan100240 Crkan100240 Crkenn04.1819.03.18 Graham19.03.18 Graham19.03.18 Graham19.03.18 Graham19.03.18 Graham25.06.18 Graham75.0618 Graham25.06.18 Graham408.24 Vas ewC) LavouMansoe ono044128). MatchDomainByEmalllntorfac3RGrahamC Opportun tvActvitwlatcheee25.06.18 GrahanSewpanazouei lennortur tevnaCtestomiedirnenont tschd nhrHe OrnenontCostrhSrond nhn25.09.19 Grahamtywiew oul teoneet todaw RayWindowCSamviceTestony uimaeumyociwiceonyUucrct winscwecuscorolo.oh© ProspectCache.pheC Hubsoot netlodedereated encontyopens yotaleyon© PayloadBulder.phALYA44442x453457473 @t>487401.0tghisosreamo»a1 nb strirwidth(Ssf'label"nhstemmarhisahe"tuneSTAOeECAPE NeOtRTiNdEn= $s['display0rder'],'is_selectable' => $s['active']"orobabsihty=> $s['probability'] * 1081f (SaissingStageNane e== Ss['id'I) (SaissingStage = $stage:Sstages = Sstage->id:is->stagesO->sync(Sstages):rerurn saissingstages* Oinher tdocpublic functsion syncûrganizationO= voidf...}Cinhendoe*euhhone iesycenrhnoubite functton evacPoneileefalleen SueenToSeanch e oullle 2pooealeleZ console (EU) * l users (EU)17201721172217321738F174-1710Oojiminny031 49 A29 V 3 У 109 A 18158, [EMAIL](U.1d, CASE NHEN U.id = t.ouner_1d THEN " (ouner) * ELSE "* END) AS user_1d,t. omen.$d FROM soctal, accounts sases mtasede eehoden nonusdon= lenlnetanes"coleame harwloe thond=xnhkhselect x tron contacrs anerid = 6331639;select * from opportunities where id = 4843610;"contont ia' - ArtiAto"onnontunitu id'n%k# 'stage_id' = 13273,"activities'. 'updated_at* = 2826-85-22 87:16:17 whereSelect * TrOuwhere created_at > ^2026-85-81':select * fromactivitales order by ild desc:"&Subrak":SELECT * FROM opportunities WHERE vuid_to_bin('04a9cfad-2c87-4453-9e72-28aeb78ccf8d*) = vuid;-tean 3d = 555,CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN " (ouner)' ELSE "* END) AS user_idsa-provider= 'hubspot':select id, is_closed, is_won, stage_updated_at, crn_provider_id, stage,id, probability.close_date, forecast_category, deleted_at, created_at, renotely_created_at, updated_atselect id, crm provider id, namefron stageshond da. 20414TO0У L7Thu 28 May 19:57:46ServiceTestmeesdales Orcnnnves dohtine Oooond+0.returned IsClosed": true staacs with probability o for lost stages, or li there was a manual overndccan you check what the probabilty of stage 20616 was in the DB before May 4? Run this query = it' tell us the exact historyO soselect id, cmn_provider_1d, nane, probability, updated at, deleted_atTron stageswhere sezoyAnd also - do you have anry sudit log or history table for stages? Something like:D soselect * fron stage_histories where stage_id = 20616 order by created at desc linit 20)select * fron activity_log wbere subject_type Like "*Stagek" and subject_id = 20616 order by created_at desc;wthotthathietoraatewastthadotaconclue.wolv talleteCanthames HoousStaac /0611012osed0na1n90 onb8rryuinhiminnvis obror the entire ocriodiromne Nov19.02sou Kumoor hrouch ad casiaMayz2oEvicenca-78616 durina that window hae Bis won - 0 = incluc no onc whoge manntcd nt he rocont ne May 26 /6951728), May 20 ( 6955703 6554495 M3Y 15 6956258 7 May47188578• Record 6054405 h90 3ful syng on May 19 uodatedlnt-sthoe uodated atidst wrote tewainao chaoe was chill orobsblity 0 on May 19• Record 6956728 was synced May 26 → still is von = 0 → stage still probability 0 on May 26Your manual sunc on May 28 timro 494813190845 - stage now orobabilityorSatha dhad waetmohs,Wonnt comat ma hotudan Mav 26 and Mav 28Why did stage 20616 have probability 07Mubscoteinore oscd on ioconv rcorcecienVonainaca orhis cusiome tncy mamcd rotAVon (cooine Sioncoiw aBur,ubsootinternadefault for closedlost is probability 0 (it's literally the "Closed Lost" built-in stage ID in HubSpot). At some point their HubSpot admin customised theThe stage.uodated.at = 2026-45-44 you saw was not when probability chanaed to 100 — it was a ditferent field vodate (Label, sequenco, tsu selectable)atetha nrohsh wmw haama hin liminnv wor racontlTha concaauanedThere are -7+ opportunities for this team in staoe.id = 20616 with iswon = 8 that need to be corrected. You can tix them a. withSET 15 won = 1, is closed = 1WHERE team 1d = 555 AND staoe 1d = 28616 AND 15 wOn = 0 AND deleted at TS NULLEAsk anything (Xol• CMwodewleintrschure Siline wankelwhises2 4 spac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88325
|
|
88324
|
rapstomProinet vViewFV faVsco.|s ~#12121 on JY-209 rapstomProinet vViewFV faVsco.|s ~#12121 on JY-20963-fx-lCoocWindowHubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoCSWnCKealCohe wWwK© WebhookSvncBatchProcelisteners> MetadataaMicrationP oedriveEh SalesforceafeldsaOpoortunityVatchenOpportunitySyncStrategyProsoec SaarchStrateosametite@ Client.phpC DecorateActivity.php( DeletcObiectsTrait.phpoewanarinithooe nha© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.phpeQuerviterator.oh© QueryResults.php© Service.php© SyncBatchRedisService.pin TraitsRaseeentono© BaseService.choe enchedcrmsarvice decoraloCrmActivityProviderinteorateCCWCMWMoron©rmobiectsteso wer.onoC.DefaultProsoectSearchStrat.C [EMAIL]) LavouMansoe ono8). MatchDomainByEmalllntorfacC Opportun tvActvitwlatcheei lennortur tevnaCtestomiedirnenont tschd nhrHe OrnenontCostrhSrond nhnCSamcetesconwalcnaedvilycimDalo.ongy uimaeumyociwiceonyvucrct uimocwecuccorolo.on(©) ProspectCache.phgACUViy.onuoowonur yoynctaionC Hubsoot netlodedereated encontyopens yotaleyon© PayloadBuilder.ohexismesa04sx5 C w20.01.2s toni-minn30.08.24 Papazo20.01.23 tont- minny19.03.18 Grahan9.05.18PEOWAPOWOY2.04.1824.0488 Graham24.04.8 Graham134105 Nikolm13.10.25 Nikolov10,0925Tan240418 Graham2404.18 Graham19.03.18 GrahamGraham1002 18 Craham2.04.18Grana2.10.252.10.2520.10.21 Graham2.10.2517.03.25 ilian16.04.25 Ivanov210.25aa4el10418GranhhimierekhaGrahin2.04.1820.1021 Graham204.1816.0425 wanok334 G33933633%rzelo stzeldarray Soptions = [i'id' a): array {...]'Label' »> "*"vatue' s> "'I1.*SnherdocHURE386 6public function inportstages(?array Stypes = null, ?string Snissingsta ,7191I!117211722try 1392394// Use the HubSpot APL client instead of the Sok craPipelines.mSendpoint = self::getDealsPipelinesEndpointO;Sorine Linesbesoonse & Sthiso>ebiente>ae instancelo ->actestent11726Spipelines = SpipelinesResponse->data-›resultscatchRequest Sycent ionBadReaueet Sexcentoioni396throw Sexcept ionf402foreach (Spipelines as Spipeline) "=175Sstages = 0Il he creste a business process to contasin the pipetine, and EnreSp = ResponseNornalize::normalizePipeline (Spipeline):404// Create/update business process for this pipeline406'era_provider_id' => $p['id'),408410)= Sthis->team->id,=> nb_strimwidth($p['Label'], start: 8, #,7/z=> BustineseProcese:TypE oppoptunaty.wseleatal= Sof'actsive' 1l/l A record type is really a clone of the business process.US ,n1eeneatelunahite necond tmon cronSthis->config->recordTypes()->update0rCreateCl'crn_provider_id' => Sp('id")1, G"teanid= Sthisosteanesid.ltywiew oul teoneet todaw RayZ console (EU) * l users (EU)100 €Oojiminny031 49 A29 V 3 У 109 A 1wsasallesforce-adminatourlane.cSELECTCONCAT(U.1d, CASE THEN U.id = t.owner_id THEN" (ouner)' ELSE" END) AS user_idv.enai?,E. omner. 3d FROM soctal, ecounts sahineane tantdre eocahiJOIN teams t l.n&->1: on t.id = u.tean_igTHERE v.team_id = 187 and sa-provider = 'salestonce':select * from activities where id = 31264367select * from contacts where id = 6331639;select * from accounts where id = 4156632;select * from opportunities where id = 4843610:# updateaccount id' = 4156632,"contont ia' - ArtiAto"onnontuntty id'nis# 'stage_id' = 13273,"activities'. "updated at' = 2826-85-22 87:16:17 where'id' = 31264367)select * from text relays where created at > 2826-85-81':select * fron activitsles order by id desciselect * fron usens where nane Uike 'gSubrak':SELECT * FROM oppontunitsies WHERE wuid to bint:04a9cfad-2087-4453-9e72-28aeb78ccf8d') = uuid:tean 3d = 555,CONCAT(U,Ád. CASE NHEN u.Sid = townen id THEM * (onner)* ELSE ** END) AS usen 5dch.*t.ouner_id FROM social_accounts saJOIN teans t (1.n<-›1: on t.id = U.tean_idTHaRs unteanside 188 andlsa-nrovider & "hubsnot"select id, is closed, is won, stage updated at, crn provider id, stage id, probabilityclose_date, forecast_category, deleted_at, created_at, renotely_created_at, updated_atprobability, updated at, deleted atfron stageshond da. 20414TO0У L7Thu 28 May 19:57:43ServiceTestCecsdales Orcnnnves dohtine Oooond+0.returned IsClosed": true staacs with probability o for lost stages, or li there was a manual overndccan you check what the probabilty of stage 20616 was in the DB before May 4? Run this query = it'l tell us the exact historyO soselect id, cmn_provider_1d, nane, probability, updated at, deleted_atTron stageswhere sezoyAnd also - do you have anry sudit log or history table for stages? Something like:D soselect * fron stage_histories where stage_id = 20616 order by created at desc linit 20)select * fron activity_log wbere subject_type Like "*Stagek" and subject_id = 20616 order by created_at desc;wthotthathietoraatewastthadotaconclue.wolv talleteCanthames HoousStaac /0611012osed0na1n90 onb8rryuinhiminnvis obror the entire ocriodiromne Nov19.02sou Kumoor hrouch ad casiaMayz2oEvicenca-78616 durina that window hae Bis won - 0 = incluc no onc whoge manntcd nt he rocont ne May 26 /6951728), May 20 ( 6955703 6554495 M3Y 15 6956258 7 May47188578• Record 6054405 h90 3ful syng on May 19 uodatedlnt-sthoe uodated atidst wrote tewainao chaoe was chill orobsblity 0 on May 19• Record 6956728 was synced May 26 → still is von = 0 → stage still probability 0 on May 26Your manual sync on May 28 tixrd 494843198845 - stage now orobability 10Satha dhad waedt mohsh"dnnt comati ma hotudan Mov 26 and Mav 28Why did stage 20616 have probability 07Mubscoteinore oscd on ioconv rcorcecienVonainaca orhis cusiome tncy mamcd rotAVon (cooine Sioncoiw aBur,ubsootinternadefault for closedlost is probability 0 (it's literally the "Closed Lost" built-in stage ID in HubSpot). At some point their HubSpot admin customised theThe stage.uodated.at = 2026-45-44 you saw was not when probability chanaed to 100 — it was a ditferent field vodate (Label, sequenco, tsu selectable)Tha concaauanedThere are -7+ opportunities for this team in staoe.id = 20616 with iswon = 8 that need to be corrected. You can tix them a. withSET 15 won = 1, is closed = 1WHERE team 1d = 555 AND staoe 1d = 28616 AND 15 wOn = 0 AND deleted at TS NULLEAsk anything (Xol• Cenires*4 space...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88324
|
|
88323
|
HomeDMsActivityFilesLater..•More+Slack> 0(ah]Fi HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:57:438 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88323
|
|
88322
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Show Replace Field
Search History
existingStages
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/2
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
1
7
149
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use HubSpot\Client\Crm\Owners\Model\PublicOwner;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'ac...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88322
|
|
88321
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
rapstomViewCoocKelucioWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet v© HubspotClientinterface.ph© HubspotTokenManager.pl© PayloadBuilder.phpoKimol CimosewhooResponseNormalize.phoCSeMCPono© SyncFieldAction.ohoCSWnCKealCohe wWwK© WebhookSvncBatchProceUisteners> MetadataaMicrationP oedriveEn SalesforceafeldsaOpoortunityVatchenOpportunitySyncStrategyProspectSearchStrateg.sametiteg Client.pheC DecorateActivity.php( DeletcObiectsTrait.phpoewanarinithooe nha© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.phpeQuerviterator.oh© QueryResults.php© Service.phg© SyncBatchRedisService.ph TraitsRaseeentono© BaseService.cho© CachedCrmServiceDecorato©CountryCodeResolver.ohdCrmActivityProviderinteorate© CrmActivitvService.ohd© CrmConficurationSettinasSe©rmobiectctesower.onC.DefaultProsoectSearchStrat.C Emal [EMAIL]) LavouMansoe ono8). MatchDomainByEmalllntorfacC Opportun tvActvitwlatchernenont tschd nhrHe OrnenontCostrhSrond nhnCSamviceTestonDeleteObjectsTrait.phgyvucrct uimocwecuccorolo.on(©) ProspectCache.phpHubotas wooitecceookotnswocoroewon© PayloadBulder.phgxsTo404ALYAclass Service extends BaseService implements= 01 A7 A149 V1 /33 /1 A v 1711pubexe tunceron anponsrogestrorroy soypes a nutt, tocring otostngscogekone - nuceht torogeGGGaмлeGdOsagadRddasI/ Use the HubSpot APT client instead of the SOK eonPipelines( nethod17121senooote seltoeieoswoeatnestnoooSonoeomeskesponseensosdatenosocdntnebosoewoten0-sreouesm0-62N0001718Sonoeotnissoro0anneskesoonsro%eo%esur1catch (RequestExcentzion|BadRequest Sexceatsion)17181throw Sexcept..onhforeach (Spipelines as Spipeline) 1ShuednocePoncoce- Sthiertoonfio.shueinoceeDnonoceoe/.sundotofofeontoff1, 0•tean 1d=> Sthis->tean->ida> nb strinaiath Spl"label'width: 150)busznessrrocess..lrre urrorionely=1734cype"ie colodtohla= so aceveИ А ророва уР 11 РУ 830 82. 489 8951039 p3, u21 e stona ahieh rerel a1/ Create/update record type cloneE1739)F174Schzs->contzo->recordTyoes-uodarcurcrea.er"con providen id' = Sofsid'11.f17121=1743"tean sidhiniowcro%e=> nb_strinwidth($p['label'], start: 0, width: 150),"e saleenhe=> Sp{'active'],hustnessnracess 0' 8>Shustness?rocesso%d77 nuaa-172Stages - tetch al existina stages uokront to avorid Mer queniedasun Sthi soscont acstagesio->where('type', Stage::TYPE_OPPORTUNITY)SootAottylwiew outrenuaet todayRayde console [PROCZ console (EU) * l users (EU)# console [STAGING]Oo liminny v031 49 A29 V 3 У 109 A 1'Stourlanex"; # 187, 289, 8158, [EMAIL](u.id, CASE WHEN U.id = t.owner_id THEN • (ouner)' ELSE "* END) AS user_idv.enai?,sa.*,t.omner_id FROM social_accounts satineane tanttreeoc.ahihTHERE v.team_id = 187 and sa-provider = 'salestonce':select * from activities where id = 31264367select * from contacts where id = 6331639;select * from accounts where id = 4156632;select * from opportunities where id = 4843610:# updateaccount id' = 4156632,"contant ia' - ArtiAto"onnontunttu id'=is#'stage_id' = 13273,"activities'. "updated at' = 2826-85-22 87:16:17 where'id' = 31264367)select * from text_relays where created_at > '2826-85-01';select * from activities order by id desc;select * fron usens where nane Uike 'gSubrak':SELECT * FROM opportunities WHERE vuid_to_bin('04a9cfad-2c87-4453-9e72-28aeb78ccf8d*) = vuid;_select * fron stages nheretean 31d = 555.CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN " (ouner)' ELSE "* END) AS user_idsa.*t.ouner_id FROM social_accounts saXomusens u on uside sh.socinhleoJOIN teans t (1.n<-›1: on t.id = U.tean_idTHans unteanside 188 andsa-nrovider & "hubsnot"select id, is closed, is won, stage updated at, crn provider id, stage id, probabilityclose_date, forecast_category, deleted_at, created_at, renotely_created_at, updated_atTrs eoper ortes tre ton .18 5 n tape ld 23 orter by yate, t tae lunt 10select id, crm provider id, nane, probability, updated at, deleted atfron stageshond ihe 2041kTO0У L7Thu 28 May 19:57:26ServiceTest+0.meesdales Orcnnnves dohtine Oooondreturned IsClosed": true stagcs with probability o for lost stages, or ll there was a manual overndccan you check what the probabilty of stage 20616 was in the DB before May 4? Run this query = it tell us the exact historyO soselect id, cmn_provider_1d, nane, probability, updated at, deleted_atTron stageswhere oe 2o6hAnd also - do you have anry sudit log or history table for stages? Something like:D soselect * fron stage_histories where stage_id = 20616 order by created at desc linit 20)select * fron activity_log wbere subject_type Like "*Stagek" and subject_id = 20616 order by created_at desc;wthotthathietoraatewastthadotaconclue.wolv talleteCanthames HoousStaac /0611012osed0na1n90 onb8rryuinhiminnvis obror the entire ocriodiromne Nov19.02sou Kumoor hrouch ad casiaMayz2oEvicenca-78616 durina that window hae Bis won - 0 = incluc no onc whore manntcd nt le he rocont ne May 26 (6951728), May 20 ( 6955703 6554495 M3Y 15 6956258 7 May47188578• Record 6054405 h90 3ful syng on May 19 uodatedlnt-sthoe uodated atidst wrote tewainao chaoe was chill orobsblity 0 on May 19• Record 6956728 was synced May 26 → still is von = 0 → stage still probability 0 on May 26Your manual sync on May 28 tixrd 494843198845 - stage now orobability 10Satha dhad waedt mohsh"dnnt comati ma hotudan Mov 26 and Mav 28Why did stage 20616 have probability 07Mubscoteinore oscd on ioconv rcorcecienVonainaca orhis cusiome tncy mamcd rotAVon (cooine Sioncoiw aBur,ubsootinternadefault for closedlost is probability 0 (it's literally the "Closed Lost" built-in stage ID in HubSpot). At some point their HubSpot admin customised theThe stage.uodated.at = 2026-45-44 you saw was not when probability chanaed to 100 — it was a ditferent field vodate (Label, sequenco, tsuselectable)Tha concaauanedThere are -7+ opportunities for this team in staoe.id = 20616 with iswon = 8 that need to be corrected. You can tix them a. withSET 15 won = 1, is closed = 1WHERE team 1d = 555 AND staoe 1d = 28616 AND 15 wOn = 0 AND deleted at TS NULL:Ask anything (Xol• Cenires*4 space...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88321
|
|
88320
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
Start Listening for PHP Debug Connections
ServiceTest
Run 'ServiceTest'
Debug 'ServiceTest'
More Actions
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:57:258 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88320
|
|
88319
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomCoocFV faVsco.|s ~#12121 on JY-20963-fx-lProinet v© HubspotClientinterface.ph© HubspotTokenManager.pt© PayloadBuilder.phpoKimol CimosewhooResponseNormalize.pho© SyncFieldAction.ohoCSWnCKealCohe wWwK© WebhookSvncBatchProceaUistenersMetadataaMicrationPoedriveEn SalesforceafeldsaOpoortunityVatchenOpportunitySyncStrategyProspectSearchStrateg.Sameatititeg Client.pheC DecorateActivity.php( DeletcObiectsTrait.phpoewanarinithooe nha© PayloadBuilder.phpc) Profile.php© QueryBuilder.php© QueryHandler.phpeQuerviterator.oh© QueryResults.php© Service.php© SyncBatchRedisService.ph TraitsC BaseClient oho© BaseService.cho© CachedCrmServiceDecoratoCrmActivityProviderinteorateCCWCMWMoronCermconticurationSettinass©rmobiectctesower.onC. DefaultProsoectSearchStrateC mallteloer.ond3Findeproscectinterace.onoC) LavouMansoe onoC Opportun tvActvitwlatcheei lennortur tevnaCtestomiedirnenont tschd nhrHe OrnenontCostrhSrond nhn424439LEGEGGGEGAGRARGSWindowCSamviceTeston(©) ProspectCache.phpHUDTOLISNOOICCCOORCTWSwocooow.onePawwaobulooironx 5 Cc W .*class Service extends BaseService implementsDIA74149 VAIY33ETAVIpubexe tunceron anponsrogestrorroy soypes a nutt, tocring otostngscogekone - nuceht torogeI Areord type s neolly a olone of the business process, used to store which record vse iCreate/update record type cloneSthis->config-›recordiypes@-›update0rCreate->aetoforeach Sol"staoes"as Sten StageSe = ResnonseNommhis 2e: nomoamebeo Sraoe Sden Stage)/** Evar ?Stage SexistingStage */Sex istinaStage = SexiistainoStaceso>oeescidDbpoctons cott.dolotod etnnoe that sno nor sativo in HunSootif (SexistángStage?->tcashedO se ssf'activor1) dCowetsnaStanostnoetanolhUpsert stage updates soft-deleted records without restoring thenSstage = Sthis->config-›stages@->withTrashed@->update0rCreatel'crn provider id' => Ssi"id']Itoon 3al= Sthis->tean->id.=> nb strinuiath(Ss["label']1ahotwirthr colwiath 101)→> So Stramazotnos Label'cype→ Scage::tre orroklonaly= Saf'disolavÖnden:1Sis selectable: => Saf'actáive*1anoosonoy= Saf'onobabáláty*1 * 1091Z console (EU) * l users (EU)100 €# console [STAGING]Oo liminny v031 49 A29 V 3 У 109 A 1'Stourlanex"; # 187, 289, 8158, [EMAIL](U.1d, CASE WHEN U.1d = t.owner_jd THEN ' (ouner)' ELSE "* END) AS user_1d,sa.*,t.omner_id FROM social_accounts saJOIN users u on u.id = sa.sociable.ioJOIN teans t (1.n<-»1: on t.id = u.tean_idTHERE v.team_id = 187 and sa-provider = 'salesfonce":coleame harwloe thond=xnhkhselect * from contacts where id = 6331639select * from accounts where id = 4156632;select * from opportunities where id = 4843610:# updateaccount id' = 4156632, 'contact id' = 6331639"onnontunttu id'=is# 'stage_id' = 13273,"activities'. 'updated_at* = 2826-85-22 87:16:17 where*1d* = 31264367)"select * from text_relays where created_at > '2826-85-01':select * fron activities order by id desc;select * fron usens wherenane like "XSubrax":SELECT * FROM opportunities WHERE uuid_to_bin('04a9cfad-2c87-4453-9e72-20aeb78ccf8d') = uuid;tean 31d = 555.CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (ouner)' ELSE "* END) AS user_idF174t. omen-id FROM soctal, accounts saJOIN users u on u.id = sa.sociable_icJOIN teans t (1.n<-›1: on t.id = U.tean,idTHaRs unteanside 188 andlsa-nrovider & "hubsnot"171211743-172select id, is closed, is won, stage updated at, crn provider id, stage id, probabilityclose_date, forecast_category, deleted_at, created_at, renotely_created_at, updated_atTirs opereatita NiT (382.30 5S e stepe 8 - 3836 ren by verte at ebe tint 19mselect id, crm provider id, nane, probability, updated at, deleted atfron stageshond da. 20414TO0У L7Thu 28 May 19:57:13ServiceTestmeesdales Orcnnnves ohtine oDon+0.returned IsClosed": true stagcs with probability o for lost stages, or ll there was a manual overndccan you check what the probabilty of stage 20616 was in the DB before May 4? Run this query = it'l tell us the exact historyO soselect id, cmn_provider_1d, nane, probability, updated at, deleted_atTron stageswhere sezoyAnd also - do you have anry sudit log or history table for stages? Something like:D soselect * fron stage_histories where stage_id = 20616 order by created at desc linit 20)select * fron activity_log wbere subject_type Like "*Stagek" and subject_id = 20616 order by created_at desc;Withourt that hictor tante waot thadota conclue.volv talle tiCanthamed HooeusStaac /0611012osed0na1n90 onb8rryuinhiminnvis obror the entire ocriodiromne Nov19.02sou Kumoor hrouch ad casiaMayz2oEvicenca-78616 durina that window hae Bis won - 0 = incluc no onc whore manntcd nt le he rocont ne May 26 (6951728), May 20 ( 6955703 6554495 M3Y 15 6956258 7 May47188578• Record 6054405 h90 3ful syng on May 19 uodatedlnt-sthoe uodated atidst wrote tewainao chaoe was chill orobsblity 0 on May 19• Record 6956728 was synced May 26 → still is von = 0 → stage still probability 0 on May 26Your manual sync on May 28 tixrd 494843198845 - stage now orobability 10Satha dhad waedt mohsh"dnnt comati ma hotudan Mov 26 and Mav 28Why did stage 20616 have probability 07Mubscoteinore oscd on ioconv rcorcecienVonainaca orhis cusiome tncy mamcd rotAVon (cooine Sioncoiw aBur,ubsootinternadefault for closedlost is probability 0 (it's literally the "Closed Lost" built-in stage ID in HubSpot). At some point their HubSpot admin customised theThe stage.uodated.at = 2026-45-44 you saw was not when probability chanaed to 100 — it was a ditferent field vodate (Label, sequenco, tsuselectable)Tha concaauanedThere are -7+ opportunities for this team in staoe.id = 20616 with iswon = 8 that need to be corrected. You can tix them a. withSET 15 won = 1, is closed = 1WHERE team 1d = 555 AND staoe 1d = 28616 AND 15 wOn = 0 AND deleted at TS NULL:Ask anything (XolAotity ll Mew oull reouest trodas 18:10)• CekltnchareWhiest4 spad...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88319
|
|
88318
|
Project: faVsco.js, menu
HomeDMsActivityFilesLater Project: faVsco.js, menu
HomeDMsActivityFilesLater..•More+Slack> 0(ah]FileEditViewGoHistoryJiminny ...* Starredplatform-backend-...platform-inner-teamChannels# ai-chapter# alerts# backend# bugs# confusion-clinic# donut_time# engineering# general# happy_birthday& infosec_internal_all# infra-changes# infrastructure_dev# jbu-team-info# jiminny-bg# platform-team# platform-tickets# product_launches# random# releases# sofia-office# support# thank-yous# the_people_of_jimi...WindowHelpSearch: in:#platform-inner-teamX*à platform-inner-teamMessagesBookmarks0 Channel Overview7 RefinementsO Files< PinsP Retro Action ItemsChanges:Monday, May 4th ~• Do not requeue duplicatesjiminny/app May 4th Added by GitHubNikolay Ivanov 1:48 PMhttps://github.com/jiminny/app/pull/12041, фикс за импорта на стейджове (само при hubspot се оказа )но има още една грешка за тях, слагаме стейджове от една организация на другаи не мога да намеря никьде в кода защона еи има две организации които не могат да се изтрият заради товаVasil Vasilev 2:07 PMимаше проблем със мачването на стейджове от една организация на другазаради кеширане на pipelineldпреди горе долу 10тина дена го гледахмеNikolay Ivanov 2:07 PMтой още го иматози пьт при stageвиж ми PRVasil Vasilev 2:08 PMpipelineld бeшe default за едно 5-6 организации, и там се омазваше кешапонеже няма нито crm coniguration id, нито team id включено в кеша дето заминава в РЕДИСа, да, това е сьщотодобре де, аз защо си мисля, че фикс за тоя проблем вече замина на продNikolay Ivanov 2:10 PMMessage & platform-inner-team+100% <78 • Thu 28 May 19:57:138 10Untitled +...
|
PhpStorm
|
faVsco.js – Hubspot/Service.php
|
NULL
|
88318
|