|
86137
|
2954
|
33
|
2026-05-28T13:48:17.031507+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976097031_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1036505436057061937
|
-8706354890773656634
|
visual_change
|
hybrid
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerreplaydWindowServerscreenpipecef_server Helper (GPU)cef_serverFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentdeletedlaunchservicesdiTerm2coreaudiodlaunchdSlack HelperActivity MonitorSlack Helper (Renderer)FirefoxSlackcef_server Helper (Renderer)language_server_macos_armWispr Flow Helper (Renderer)Firefox GPU HelperFirefoxCP Isolated Web ContentWispr FlowControl Centrelogd203,7153,369,463,860,229,322,711,210,710,610,28,46,26,25,55,25,04,94,84,24,23,73,42,72,52,22,01,8CPU Time22:12:26,912:08:39,431:32:25,395:32:14,427:42:41,673:28:53,006:31,403:08,094:46,1331:52,6643,9259:09,991:02:26,8855:59,4818:17,3913:29,725:55,5256:22,441:45:38,0712:55,346:46,825:50,053:10,002:23:20,3314:43,532:11,6919:29,9115:01,70System:User:Idle:Threads % CThu 28 May 16:48:16CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple49,24%40,76%10,00%0,027,10,00,054,50,07,50,00,00,00,00,00,20,00,01,60,00,00,00,00,00,00,00,10,00,00,00,0CPU LOADThreads:Processes:GPU Time0,006:27,590,000,003:16:07,585:03.901:52,530,000,000,000,000,0017,750,000,005:48,960,000,000,040,050,000,000,0023:51,070,000,000,040,00PID lahlUserrootlukaslukaslukas_windowserverlukaslukaslukaslukaslukaslukasrootlukas_coreaudiodrootlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootSearch...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86136
|
2954
|
32
|
2026-05-28T13:48:13.809368+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976093809_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"}]...
|
7811291268328512625
|
-8708606742126949438
|
visual_change
|
hybrid
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
Activity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskVirtual Machine Service for DockerPhpStormWindowServerreplaydscreenpipecef_server Helper (GPU)FirefoxCP Isolated Web Contentcef_serverFirefoxCP Isolated Web ContentdeletedcoreaudiodbackupdmobileassetdSlack HelperFirefoxCP Isolated Web ContentiTerm2Activity MonitorFirefoxFirefoxCP Isolated Web ContentFirefoxCP Isolated Web Contentlanguage_server_macos_armSlack Helper (Renderer)cef_server Helper (Renderer)FirefoxCP Isolated Web ContentlaunchservicesdWispr Flow Helper (Renderer)FirefoxCP Isolated Web Content195,3115,4104,460,040,339,722,410,810,810,56,96,46,46,25,75,65,65,55,15,04,74,74,74,44,24,03,43,1CPU Time22:12:16,011:32:21,672:08:31,237:42:38,455:32:11,013:28:51,436:30,194:45,553:07,4931:52,1043,3755:59,1549,9528,8013:29,4411:01,051:02:26,555:55,251:45:37,823:18,461:57,835:49,8556:22,186:46,6029:05,2259:09,533:09,8222:13,99System:User:Idle:Threads lahl100% <78•Thu 28 May 16:48:13CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,00,026,352,40,00,07,40,00,00,00,00,00,00,01,60,00,20,00,00,00,00,00,00,00,00,00,00,049,82%48,78%1,41%CPU LOADThreads:Processes:SearchGPU Time0,000,006:26,443:16:05,220,005:03.901:52,190,000,000,000,000,000,000,005:48,890,0017,740,000,040,000,000,000,000,000,000,000,000,00PID Userrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukas_coreaudiodrootrootlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootlukaslukas...
|
86134
|
NULL
|
NULL
|
NULL
|
|
86135
|
2955
|
29
|
2026-05-28T13:48:07.707805+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976087707_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6423983795013307969
|
-3852565242246887264
|
idle
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerce traitsCleentontCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.onpyTeom.oneuser.3 Бclass Service extends BaseService inolesents.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyinterclprowdwewe ehr nong PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacskenorccner ylookoo ncertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiServiceTestDeleteObjectsTrait.php© Client.phpTO0У L7Thu 28 May 16:48:07+0.714-716=custom.loglaravel.logSetm onwatoes noeslweleelrecoeoehoe& console (PROD) XA console (STAGING)AND A-created at DATE SURMOWOL TNTERVAI 3A DAYINCROup RY u.sd m.enaial, maname, M, softohone nunberORDER RY sns count nESKselect * fron teans where id = 1COlOAt& GAAn AAld0CGIGOt.ounerid FROM social accounts saJOIN users u on v.id = sa.sociable.ioJOTN teans + 1.n<->1: on t.id = u.team_idTHERE u,tean Sid = 1117 and sauonowiden = "hubsoot*.SELECT * FROM activities WHERE uuid_to_bin('8024fffb-2df7-4017-91f4-d9f896050248') = uuid; # 79933459 YESSELECT * FROM activities WHERE uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a818Sc') = uuid; # 80186192 NQSELECT * FROM crn_configurations WHERE id = 1053;SSLEGT + FROM teans WHEDE 3d = 1117users where id = 30249select * fron playbooks where id = 5473:select * fron playbook_categories where id = 43783;select + Eron nlavhonk cateaon es nhere nilavbook5d 5/72)select + Eron er Geld vallues nhene crn Beld 3d = 450242)SFIEM & EROM eo 6eld data €I0TM con frelde + 0M Eilooe Sield Sd= €5oIOTN sctustae anM Edlactausty Sd =a5oWHERE activityid = 79933459ANO& AAR ARAUSANA SAe the dAfdudty PUAAESELECT * FROM activity messagesPAlRAtGhn LANt AAlANA WhAnA AnAAtAAAN EnOIROG OI# console fiaum045 A1 A41 У 66 4CascaoklesOrcherwichotixind hlesorceTu analyze the codebase to understand the fiow and verity your descriotion. Let me scarch for the relevant code60 Fast Context RematchActivityOnCrmObjectDetachlstener in 9.2268 Fast Context importOpportunity IsDeleted handieObjectDeletion in 8.68Fast context restorceAny.rashedentity in 8.02Searched updateActivityCrmData and performed 1 other queryFAAMOIM DCN NNIMHMN INM-732734735=745I1 H1MCommand cdl, ait, beasCommand odld aahdselect * fron accounts where tean_id = 1 and name = 'Colunns"select * fron users where name Like "XSubrax*: = 31654, 1117select * fron teans where 1d = 1117select + fron activity searches nhere user 1a = 31854select + fron activity search &iltens where activity seanch fal TN (RRRR). RROA)"AdhotisM Wodtur Teams 1108-50 UTE-R AlAend...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86134
|
2954
|
31
|
2026-05-28T13:47:52.330021+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976072330_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-5925516468550469094
|
-8643040614315223040
|
visual_change
|
hybrid
|
NULL
|
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'
Activity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerWindowServerreplaydSlack Helper (Renderer)cef_server Helper (GPU)screenpipecef_servermds_storesFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentlaunchservicesdiTerm2coreaudiodRaycast Helper (Extensions)Slack HelperActivity MonitorRaycastcef_server Helper (Renderer)Firefoxlanguage_server_macos_armFirefoxCP Isolated Web ContentWispr Flow Helper (Renderer)clouddauditdFirefox GPU HelperFirefoxCP Isolated Web Content207,3110,9100,160,939,627,121,714,111,111,110,510,37,77,46,46,05,34,84,34,34,13,73,63,43,23,12,92,7CPU Time22:11:35,452:08:04,671:31:58,657:42:25,665:32:00,0256:21,206:25,593:28:45,203:05,261:02:43,5231:49,724:43,2959:08,601:02:25,2855:57,7833,7113:28,305:54,053:59,236:45,681:45:36,795:48,983:17,953:09,1121,088:32,412:23:19,5914:42,72System:User:Idle:Threads lahl100% CThu 28 May 16:47:51CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU1151847,44%52,33%0,23% AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,026,40,053,80,00,07,00,00,00,00,00,00,00,20,00,01,60,00,00,00,00,00,00,00,00,00,10,0CPU LOADThreads:Processes:SearchGPU Time0,006:22,030,003:15:56,310,000,001:51,005:03,900,000,000,000,000,0017,710,000,005:48,650,000,060,000,040,000,000,000,000,0023:51,050,00PID Userrootlukaslukas_windowserverlukaslukaslukaslukaslukasrootlukaslukasrootlukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootlukaslukas...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86133
|
2954
|
30
|
2026-05-28T13:47:49.172548+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976069172_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8772026000079251515
|
-8704103417486652480
|
visual_change
|
hybrid
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
Activity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerreplaydWindowServercef_server Helper (GPU)screenpipecef_serverFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentiTerm2logdcoreaudiodFirefoxlaunchdSlack HelperActivity MonitorDockerSlack Helper (Renderer)cef_server Helper (Renderer)language_server_macos_armWispr Flow Helper (Renderer)launchservicesdSlackFirefoxCP Isolated Web ContentBitwardenFirefox GPU Helperauditd195,8138,2100,462,859,821,913,810,810,39,87,97,16,25,75,45,35,15,04,84,33,43,43,13,12,92,92,82,7CPU Time22:11:24,512:07:58,811:31:53,375:31:57,937:42:22,446:24,443:28:44,453:04,6831:49,174:42,741:02:24,8915:01,1955:57,441:45:36,5818:16,6913:28,025:53,791:07,1856:19,786:45,465:48,793:08,9359:08,2012:54,7314:42,581:54,542:23:19,448:32,25System:User:Idle:Threads (ah)100% CThu 28 May 16:47:48CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,026,90,00,055,07,20,00,00,00,00,20,00,00,00,01,60,00,00,00,00,00,00,00,00,00,00,10,047,60%49,70%2,70%CPU LOADThreads:Processes:SearchGPU Time0,006:20,990,000,003:15:54,131:50,735:03,900,000,000,0017,700,000,000,040,005:48,580,000,000,000,000,000,000,000,050,000,0023:51,050,00PID Userrootlukaslukaslukas_windowserverlukaslukaslukaslukaslukaslukasroot_coreaudiodlukasrootlukaslukaslukaslukaslukaslukaslukasrootlukaslukaslukaslukasroot...
|
86129
|
NULL
|
NULL
|
NULL
|
|
86132
|
2955
|
28
|
2026-05-28T13:47:36.138492+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976056138_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"bounds":{"left":0.35272607,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"bounds":{"left":0.3643617,"top":0.12529927,"width":0.012632979,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.37898937,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"bounds":{"left":0.38896278,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.40026596,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7032205405598293203
|
-8704103417419559998
|
visual_change
|
hybrid
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAeut Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectsTrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.onpyTeom.oneuser.g gclass Service extends BaseService nol esents.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyinterclprowdwewe ehr nong PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacskenorccner ylookoo ncertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Keso Veconoanynaneoyrhartiruse Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate coner nt kisei wiiis tnhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000-MimnarM Kinckprivate const steing INSTALLED PACKAGE70 = *В33Т-60009876K67AT*• M MaiServiceTestDeleteObjectsTrait.php© Client.php=707глaлаnuuы714-716=custom.loglaravel.logSetm onwatoes noeslweleelrecoeoehoe& console (PROD) X© Service.php# console fiaumA console (STAGING)AND A-created at DATE SURMOWOL TNTERVAI 3A DAYIN045 A1 A41 У 66 4CROuP RY u.sd urenaitl, aname, ,softohone nunberORDER RY sns count nESKselect * fron teans where id = 1COlOAt& GAAn AAld0CGIGOt.ounerid FROM social accounts saJOIN users u on v.id = sa.sociable.ioJOTN teans + 1.n<->1: on t.id = u.team_idTHERE u,tean Sid = 1117 and sauonowiden = "hubsoot*.SELECT * FROM crn_configurations WHERE id = 1053;SSLEGT + FROM teans WHEDE 3d = 1117users where id = 30249select * fron playbooks where id = 5473:select * fron playbook_categories where id = 43783;select + Eron nlavhonk cataaon es nhere nilavbook5d 5672select + Eron er Geld vallues nhene crn Beld 3d = 450242)SFIEM & EROM eo Eeld data €aI0TM con frelde + 0M &ilooe Sreld 3d = €60IOTN sctustfae a nM Edl actáusty dd=aaoWHERE activityid = 79933459OANO& AAR ARAUSARA SAe Ihe GAfGuStU RUANESELECT * FROM activity messagesPAlRAtGhAs LANt AAlAuA WhAnA AnAIAAA EOGOIlesOrcherwichotixind hlesorceThu 28 May 16:47:36+0.M EI MMHMMNNIN MM-732734735=745M I1 11Iselect * fron accounts where tean_id = 1 and name = 'Colunns"select * fron users where name Like "XSubrax*: = 31654, 1117select * fron teans where 1d = 1117select + fron activity searches nhere user $a = 31854%select + fron activity search &iltens where activity seanch fal TN (RRRR). RROA)"...
|
86131
|
NULL
|
NULL
|
NULL
|
|
86131
|
2955
|
27
|
2026-05-28T13:47:23.777585+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976043777_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6423983795013307969
|
-3852565242246887264
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAeut Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherepportun tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseService inolesents.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginby-nazuintertace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacskenorccner ylookoo ncertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.php714-716=custom.loglaravel.logSetm onwatoes noeslweleelrecoeoehoed console (PROD) x C Service.php# console fiaumA console (STAGING)AND A-created at DATE SURMOWOL TNTERVAI 3A DAYIN045 A1 A41 У 66 4CROuP RY u.sd urenaitl, aname, ,softohone nunberORDER RY sns count nESKselect * fron teans where id = 1COlOAt& GAAn AAld0CGIGOCONCAT(u.id, CASE WHEN u.id = t.ouner_id THEN " (ouner)' ELSE "* END) AS user_id,t.ounerid FROM social accounts saJOIN users u on v.id = sa.sociable.ioJOTN teans + 1.n<->1: on t.id = u.team_idTHERE u,tean Sid = 1117 and sauonowiden = "hubsoot*.SELECT * FROM activities WHERE uuid_to_bin('8024fffb-2df7-4017-91f4-d9f896050248') = uuid; # 79933459 YESSELECT * FROM activities WHERE uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a818Sc') = uuid; # 80186192 NQSELECT * FROM crn_configurations WHERE id = 1053;SSLEGT + FROM teans WHEDE 3d = 1117users where id = 30249select * fron playbooks where id = 5473:select * fron playbook_categories where id = 43783;select + Eron nlavhonk cateaon es nhere nilavbook5d 5/72)select + Eron er Geld vallues nhene crn Beld 3d = 450242)SFIEM & EROM eo 6eld data €I0TM con frelde + 0M Eilooe Sield Sd= €5oIOTM sctustfee a nM Eil actavsty Sd=aanWHERE activityid = 79933459ANO& AAR ARAUSANA SAe the dAfdudty PUAAESELECT * FROM activity messagesPAlRAtGhAs LANt AAlAuA WhAnA AnAIAAA EOGOITO0У L7Thu 28 May 16:47:23ServiceTestCascaoklesOrcherwichoaixindlesorce+0.wepurtunielatchActivityOn@rmOblectDetach listener. During this rematch, the system attempts tore-link the activity to the deleted opportunity, causing a foreign key constraint violation.The bug occurs in Salesforce: importOpportunity (and similar methods for Lead, Account, Contact);= Muo, un wwoeTht oatity oportuhy d. Couthe oredin hety rafedstersteit he oea toThought for 2sanswehe coscorsro.unow.onotheowanoweniwtouroutthotorlemeshrchhtor.nerclcan..cooFast context RematchactivityOnCrmobicctDetachstener.in 972rast context importopportunity isbeletca handicob cctacicton in 8.085rast Context restoreAny.rashedtnlity in 8.00gLet me scarch for the uodateact vitycredata method and understand the fiow betterThought for1Let me check the git history to see the specific fix that was applied for this issue• Ke 7soe/ekas/rematey/ Be ac gie200% evenutl TT ai Tog peofetenen2/dev/nuli | head -30FAAMOIM DCN NNIMHMN INM-732734735=745I1 H1Mselect * fron accounts where tean_id = 1 and name = 'Colunns"select * fron users where name Like "XSubrax*: = 31654, 1117select * fron teans where 1d = 1117select + fron activity searches nhere user 1a = 31854%select + fron activity search &iltens where activity seanch fal TN (RRRR). RROA)"Ask anything (XoL)c HodeAdhotvXtModtur Tatme Toxcsehireht4 spaTOD...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86130
|
2955
|
26
|
2026-05-28T13:47:19.035534+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976039035_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20 rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyinterclprowdwewe ehr nong PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate conet nt hisei Wins teeweheyh*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGE70 = *633T-60009876K67AT*-• M MaiDeleteObjectsTrait.php© Client.phpA SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaum045 A1 A41 У 66 ATO0У L7Thu 28 May 16:47:18ServiceTestCascaolesOrcherwichotixind hlesorce+0.n: When an opportunityrematched via the RomatchActivityOnCrmOblectDetach listener. During this rematch, the system attemots tore-link the activity to the deleted opportunity, causing a foreign key constraint violation.The bug occurs in Salesforce importOpportunity (and similar methods for Lead, Account, Contact):ГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > *2826-85-22' order by sid descteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02and ve the coo coisa to underoind the ow dnd veniy wour cestotonlemeshrchtortheralcan.cooFast Contextrast context imporiOpportunity IsDeletca handicoo cctoehLet me scarch for the uodatcActivitycredata method and understand the fiow betteiScarchod wndateActivtvermbnth and portormadother queryLet me check the git history to see the specific fix that was applied for this issueeommand colobonKe 1 12soe /eukos/renatcylP8e tacn °i2202 -evntl TTgl og prenetinen2Ydev/null | head =30Ask anything (XoL)c HodeAdhotv• OXtModtur Tatme Toxcsehireht4 spad...
|
NULL
|
-8423656818507995989
|
NULL
|
click
|
ocr
|
NULL
|
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20 rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyinterclprowdwewe ehr nong PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate conet nt hisei Wins teeweheyh*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGE70 = *633T-60009876K67AT*-• M MaiDeleteObjectsTrait.php© Client.phpA SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaum045 A1 A41 У 66 ATO0У L7Thu 28 May 16:47:18ServiceTestCascaolesOrcherwichotixind hlesorce+0.n: When an opportunityrematched via the RomatchActivityOnCrmOblectDetach listener. During this rematch, the system attemots tore-link the activity to the deleted opportunity, causing a foreign key constraint violation.The bug occurs in Salesforce importOpportunity (and similar methods for Lead, Account, Contact):ГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > *2826-85-22' order by sid descteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02and ve the coo coisa to underoind the ow dnd veniy wour cestotonlemeshrchtortheralcan.cooFast Contextrast context imporiOpportunity IsDeletca handicoo cctoehLet me scarch for the uodatcActivitycredata method and understand the fiow betteiScarchod wndateActivtvermbnth and portormadother queryLet me check the git history to see the specific fix that was applied for this issueeommand colobonKe 1 12soe /eukos/renatcylP8e tacn °i2202 -evntl TTgl og prenetinen2Ydev/null | head =30Ask anything (XoL)c HodeAdhotv• OXtModtur Tatme Toxcsehireht4 spad...
|
86127
|
NULL
|
NULL
|
NULL
|
|
86129
|
2954
|
29
|
2026-05-28T13:47:18.912981+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976038912_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerWindowServerreplaydscreenpipecef_server Helper (GPU)cef_serverFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxcoreaudiodlaunchservicesdiTerm2logdActivity MonitorSlack HelperSlack Helper (Renderer)cef_server Helper (Renderer)language_server_macos_armWispr Flow Helper (Renderer)Firefox GPU HelperSlackFirefoxCP Isolated Web ContentdeletedWispr Flowlaunchdbackupd202,8142,5118,460,539,425,120,311,611,010,96,46,36,36,05,75,45,34,53,63,53,53,02,92,72,12,01,71,6CPU Time22:10:32,372:07:28,781:31:26,547:42:07,125:31:42,213:28:35,636:18,453:01,6731:46,414:40,291:45:34,9655:55,7759:06,481:02:23,2114:59,555:52,3613:26,6656:18,286:41,265:45,863:08,062:23:18,7712:54,2614:41,7942,942:10,4118:16,2349,36System:User:Idle:Threads CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind % GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple58,18%38,99%2,83%0,026,70,055,50,00,06,00,00,00,00,00,00,00,10,00,01,50,00,00,00,00,10,00,00,00,00,00,0CPU LOADThreads:Processes:GPU Time0,006:16,030,003:15:44,440,005:03,901:49,250,000,000,000,040,000,0017,670,000,005:48,270,000,000,000,0023:51,030,050,000,000,000,000,00‹$0lahlPID % <78•Thu 28 May 16:47:18SearchUserrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukas_coreaudiodrootlukasrootlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootroot...
|
NULL
|
7889349020220069680
|
NULL
|
click
|
ocr
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerWindowServerreplaydscreenpipecef_server Helper (GPU)cef_serverFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxcoreaudiodlaunchservicesdiTerm2logdActivity MonitorSlack HelperSlack Helper (Renderer)cef_server Helper (Renderer)language_server_macos_armWispr Flow Helper (Renderer)Firefox GPU HelperSlackFirefoxCP Isolated Web ContentdeletedWispr Flowlaunchdbackupd202,8142,5118,460,539,425,120,311,611,010,96,46,36,36,05,75,45,34,53,63,53,53,02,92,72,12,01,71,6CPU Time22:10:32,372:07:28,781:31:26,547:42:07,125:31:42,213:28:35,636:18,453:01,6731:46,414:40,291:45:34,9655:55,7759:06,481:02:23,2114:59,555:52,3613:26,6656:18,286:41,265:45,863:08,062:23:18,7712:54,2614:41,7942,942:10,4118:16,2349,36System:User:Idle:Threads CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind % GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple58,18%38,99%2,83%0,026,70,055,50,00,06,00,00,00,00,00,00,00,10,00,01,50,00,00,00,00,10,00,00,00,00,00,0CPU LOADThreads:Processes:GPU Time0,006:16,030,003:15:44,440,005:03,901:49,250,000,000,000,040,000,0017,670,000,005:48,270,000,000,000,0023:51,030,050,000,000,000,000,00‹$0lahlPID % <78•Thu 28 May 16:47:18SearchUserrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukas_coreaudiodrootlukasrootlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootroot...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86128
|
2954
|
28
|
2026-05-28T13:47:14.716225+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976034716_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1036505436057061937
|
-8706354890773656634
|
idle
|
hybrid
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Activity MonitorFileActivity MonitorAll ProcessesProcess Namekernel_taskVirtual Machine Service for DockerPhpStormWindowServerreplayddeletedcef_server Helper (GPU)screenpipecef_serverFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxiTerm2FirefoxCP Isolated Web ContentcoreaudiodActivity MonitorSlack Helper (Renderer)FirefoxSlack HelperFirefoxCP Isolated Web ContentlaunchdFirefoxCP Isolated Web Contentlanguage_server_macos_armlaunchservicesdFirefoxCP Isolated Web Contentcef_server Helper (Renderer)Wispr Flow Helper (Renderer)FirefoxCP Isolated Web ContentEditViewWindowHelp% CPU185,7116,4102,765,564,921,720,315,211,811,210,77,67,06,46,35,55,45,25,25,14,34,34,13,83,73,63,53,0CPU Time22:10:21,591:31:20,252:07:21,207:42:03,905:31:40,1142,836:17,373:28:34,293:01,064:39,7131:45,821:45:34,621:02:22,903:17,3955:55,445:52,0856:18,0513:51,4113:26,3816:40,9718:16,1410:59,845:45,6859:06,1422:13,006:41,073:07,8729:04,33System:User:Idle:Threads CPUMemoryIdle Wake-Ups EnergyDiskKindAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleCPU LOAD49,94%49,83%0,23%Network% GPU0,00,027,156,10,00,06,10,00,00,00,00,00,10,00,00,00,00,01,50,00,00,00,00,00,00,00,00,0Threads:Processes:GPI3:1(ah)100% CThu 28 May 16:47:14SearchPID Userrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukaslukaslukaslukas_coreaudiodlukaslukaslukaslukaslukasrootlukaslukasrootlukaslukaslukaslukas...
|
86126
|
NULL
|
NULL
|
NULL
|
|
86127
|
2955
|
25
|
2026-05-28T13:46:56.172888+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976016172_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6423983795013307969
|
-3852565242246887264
|
idle
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentontCDecmrate-cmwonnDeleteObjectsTrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyinterclprowdwewe ehr nong PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate conet nt hisei Wins teeweheyh*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGE70 = *633T-60009876K67AT*-• M MaiDeleteObjectsTrait.php© Client.phpA SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaum045 A1 A41 У 66 ATO0У L7• Thu 28 May 16:46:55ServiceTestCascaolesOrcherwichotixind hlesorce+0.Opportunnsrematched via the RomatchActivityOnCrmOblectDetach listener. During this rematch, the system attemots tore-link the activity to the deleted opportunity, causing a foreign key constraint violation.The bug occurs in Salesforce:importOpportunity (and similar methods for Lead, Account, Contact):ГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2926-05-91*-* fron actsivities where user_id TN (7168, 18688) and created at > *2826-85-22' order by sid descteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02and ve the coo coisa to underoind the ow dnd veniy wour cestotonlemeshrchtortheralcan.cooFast Contextrast context imporiOpportunity IsDeletca handicoo cctoehLet me scarch for the uodatcActivitycredata method and understand the fiow betteiScarchod wndateActivtvermbnth and portormadother queryLet me check the git history to see the specific fix that was applied for this issueeommand colobonKe 1 12soe /eukos/renatcylP8e tacn °i2202 -evntl TTgl og prenetinen2Ydev/null | head =30Run+skoAsk anything (XoL)c HodeAdhotv• OXtModtur Tatme Toxcsehireht4 spad...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86126
|
2954
|
27
|
2026-05-28T13:46:43.465597+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779976003465_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6806313800532383212
|
-8129894190043395130
|
idle
|
hybrid
|
NULL
|
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
Sync Changes
Hide This Notification
Activity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskVirtual Machine Service for DockerPhpStormWindowServerreplaydlanguage_server_macos_armcef_server Helper (GPU)trialdcef_serverscreenpipeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxcoreaudiodiTerm2FirefoxCP Isolated Web ContentSlack HelperFirefoxCP Isolated Web ContentActivity MonitorFirefoxCP Isolated Web ContentSlack Helper (Renderer)FirefoxCP Isolated Web Contentcef_server Helper (Renderer)FirefoxCP Isolated Web ContentWispr Flow Helper (Renderer)backupdFirefoxlaunchservicesd192,6111,389,971,139,723,021,513,913,412,410,19,87,96,45,85,75,55,45,24,74,53,93,73,73,53,43,33,1CPU Time22:09:20,761:30:46,442:06:44,567:41:44,015:31:22,735:44,406:11,128,612:57,383:28:25,354:36,3231:42,311:45:32,6355:53,421:02:21,033:16,8413:24,7229:03,665:50,462:06,9056:15,1216:39,876:39,9022:12,343:06,8049,0313:50,9359:04,62System:User:Idle:Threads lahl100% CThu 28 May 16:46:43CPUMemoryEnergyDiskIdle Wake-UpsKind AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleCPU LOAD51,17%47,08%1,76%Network% GPU0,00,026,552,40,00,010,20,00,00,00,00,00,00,00,10,01,70,00,00,00,00,00,00,00,00,00,00,0Threads:Processes:Search0,000,000,000,000,000,000,000,000,040,000,000,000,000,000,000,000,000,000,000,000,010,00PID Userrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukaslukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootlukasroot...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86125
|
2955
|
24
|
2026-05-28T13:46:24.025889+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975984025_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20 rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentontCDecmrate-cmwonnDeleteObjectsTrait.phpcFadse intonsrno© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo# ProsnectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate conet nt hisei Wins teeweheyh*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGE70 = *633T-60009876K67AT*-• M MaiDeleteObjectsTrait.php© Client.phpA SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaum045 A1 A41 У 66 ATO0У L7oo nu comoy 10:40i2eServiceTestCascaoles orcesirwichotixind hlesorce+0.Opportunnsrematched via the RomatchActivityOnCrmOblectDetach listener. During this rematch, the system attemots tore-link the activity to the deleted opportunity, causing a foreign key constraint violation.The bug occurs in Salesforce:importOpportunity (and similar methods for Lead, Account, Contact):ГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2926-05-91*-* fron actsivities where user_id TN (7168, 18688) and created at > *2826-85-22' order by sid descteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02and ve the coo coisa to underoind the ow dnd veniy wour cestotonlemeshrchtortheralcan.cooFast Contextrast context imporiOpportunity IsDeletca handicoo cctoehLet me scarch for the uodatcActivitycredata method and understand the fiow betteiScarchod wndateActivtvermbnth and portormadother queryLet me check the git history to see the specific fix that was applied for this issueeommand colobondKe 1 12soe /eukos/renatcylP8e tacn °i2202 -evntl TTgl og prenetinen2Ydev/null | head =30Run+skoAsk anything (XoL)c HodeAdhotv• OXtModtur Tatme Toxcsehireht4 spad...
|
NULL
|
-5786569271879633757
|
NULL
|
visual_change
|
ocr
|
NULL
|
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20 rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentontCDecmrate-cmwonnDeleteObjectsTrait.phpcFadse intonsrno© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo# ProsnectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate conet nt hisei Wins teeweheyh*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGE70 = *633T-60009876K67AT*-• M MaiDeleteObjectsTrait.php© Client.phpA SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaum045 A1 A41 У 66 ATO0У L7oo nu comoy 10:40i2eServiceTestCascaoles orcesirwichotixind hlesorce+0.Opportunnsrematched via the RomatchActivityOnCrmOblectDetach listener. During this rematch, the system attemots tore-link the activity to the deleted opportunity, causing a foreign key constraint violation.The bug occurs in Salesforce:importOpportunity (and similar methods for Lead, Account, Contact):ГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2926-05-91*-* fron actsivities where user_id TN (7168, 18688) and created at > *2826-85-22' order by sid descteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02and ve the coo coisa to underoind the ow dnd veniy wour cestotonlemeshrchtortheralcan.cooFast Contextrast context imporiOpportunity IsDeletca handicoo cctoehLet me scarch for the uodatcActivitycredata method and understand the fiow betteiScarchod wndateActivtvermbnth and portormadother queryLet me check the git history to see the specific fix that was applied for this issueeommand colobondKe 1 12soe /eukos/renatcylP8e tacn °i2202 -evntl TTgl og prenetinen2Ydev/null | head =30Run+skoAsk anything (XoL)c HodeAdhotv• OXtModtur Tatme Toxcsehireht4 spad...
|
86124
|
NULL
|
NULL
|
NULL
|
|
86124
|
2955
|
23
|
2026-05-28T13:46:20.862165+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975980862_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6423983795013307969
|
-3852565242246887264
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFadse intonsrno© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo# ProsnectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseService inolesents.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyinterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacskenorccner ylookoo ncertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt hisei Wins teeweheyh*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.phpA SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaum045 A1 A41 У 66 ATO0У L7Inu cowoy 10.40ilServiceTestCascaokles orcesirwichoaixind sles orcee+0.SepurtunitlatchActivityOn@rmOblectDetach listener. During this rematch, the system attempts tore-link the activity to the deleted opportunity, causing a foreign key constraint violation.The bug occurs in Salesforce: importOpportunity (and similar methods for Lead, Account, Contact);= Muo, un wooeTht oatity oportuiy d. Couthe oredin hety rafedstersteit he oea toГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2926-05-91*-* fron actsivities where user_id TN (7168, 18688) and created at > *2826-85-22' order by sid descteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antéusty seanches whene usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02answuethecoscorsrounow.onotheowanoweniwtouroetrnotorlemeshrchhtor.nerclcan..cooFast context RematchactivityOnCrmobicctDetachLstener in 9727rast context importopportunity isbeletca handicob ccta cicion in 8.085rast Context restoreAnytrashedenuty in 8.00gLet me scarch for the uodateact vitycredata method and understand the fiow betterThought for1Let me check the git history to see the specific fix that was applied for this issueKe 17soe/lukos/renatcrlP8e tacn °i2202 -evntt TT gl Tog pronetine2Ydev/null | head =30Run+skoAsk anything (XoL)c HodeAdhotv• OXtModtur Tatme Toxcsehireht4 spad...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86123
|
2954
|
26
|
2026-05-28T13:46:11.408826+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975971408_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
IAlActivity MonitorFileEditViewWindowHelpActivity IAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerWindowServerreplaydscreenpipecef_server Helper (GPU)language_server_macos_armcef_server Helper (Renderer)cef__serverFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentSlack Helper (Renderer)coreaudiodlaunchservicesdActivity MonitorNotion Calendar Helper (Renderer)iTerm2Slack HelperFirefoxFirefoxCP Isolated Web ContentDockerWispr Flow Helper (Renderer)FirefoxCP Isolated Web ContentFirefox GPU HelperFirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web Content175,9106,9105,756,652,532,524,215,913,212,610,69,97,46,16,15,95,95,85,75,34,63,83,32,82,82,12,12,0CPU Time22:08:17,352:06:10,481:30:15,867:41:24,235:31:04,933:28:16,096:03,975:40,346:35,882:53,1731:39,004:33,2056:13,6955:51,2759:02,695:48,739:30,841:02:19,0713:22,991:45:30,3616:38,781:05,383:05,7114:39,842:23:16,8723:33,182:08,9429:02,65System:User:Idle:Threads CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple54,10%45,78%0,12%CPU LOAD0,023,50,051,90,00,011,60,00,00,00,00,00,00,00,00,00,00,22,70,00,00,00,00,00,20,00,00,0Threads:Processes:GPU Time0,006:00,250,003:15:12,100,005:03.901:45,630,000,000,000,000,000,000,000,000,000,0017,595:47,370,040,000,000,000,0023:50,990,000,000,00‹ >0 (hlPID Userrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukaslukaslukas_coreaudiodrootlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukas100% CThu 28 May 16:46:11Search...
|
NULL
|
-2134895921259138954
|
NULL
|
visual_change
|
ocr
|
NULL
|
IAlActivity MonitorFileEditViewWindowHelpActivity IAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerWindowServerreplaydscreenpipecef_server Helper (GPU)language_server_macos_armcef_server Helper (Renderer)cef__serverFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentSlack Helper (Renderer)coreaudiodlaunchservicesdActivity MonitorNotion Calendar Helper (Renderer)iTerm2Slack HelperFirefoxFirefoxCP Isolated Web ContentDockerWispr Flow Helper (Renderer)FirefoxCP Isolated Web ContentFirefox GPU HelperFirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web Content175,9106,9105,756,652,532,524,215,913,212,610,69,97,46,16,15,95,95,85,75,34,63,83,32,82,82,12,12,0CPU Time22:08:17,352:06:10,481:30:15,867:41:24,235:31:04,933:28:16,096:03,975:40,346:35,882:53,1731:39,004:33,2056:13,6955:51,2759:02,695:48,739:30,841:02:19,0713:22,991:45:30,3616:38,781:05,383:05,7114:39,842:23:16,8723:33,182:08,9429:02,65System:User:Idle:Threads CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple54,10%45,78%0,12%CPU LOAD0,023,50,051,90,00,011,60,00,00,00,00,00,00,00,00,00,00,22,70,00,00,00,00,00,20,00,00,0Threads:Processes:GPU Time0,006:00,250,003:15:12,100,005:03.901:45,630,000,000,000,000,000,000,000,000,000,0017,595:47,370,040,000,000,000,0023:50,990,000,000,00‹ >0 (hlPID Userrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukaslukaslukas_coreaudiodrootlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukas100% CThu 28 May 16:46:11Search...
|
86122
|
NULL
|
NULL
|
NULL
|
|
86122
|
2954
|
25
|
2026-05-28T13:46:08.349308+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975968349_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8043719072324535154
|
-8628527368849355612
|
visual_change
|
hybrid
|
NULL
|
Project: faVsco.js, menu
Activity MonitorFileEditV Project: faVsco.js, menu
Activity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskVirtual Machine Service for DockerPhpStormcef_server Helper (Renderer)WindowServercef_server Helper (GPU)replaydscreenpipeio.kandji.KandjiAgent.ESF-Extensionlanguage_server_macos_armcef_serverFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentcoreaudiodSlack HelperiTerm2Activity MonitorFirefoxFirefoxCP Isolated Web ContentSlack Helper (Renderer)FirefoxCP Isolated Web ContentcorebrightnessdControl CentreWispr Flow Helper (Renderer)SlackFirefox GPU HelperFirefoxCP Isolated Web ContentFirefoxCP Isolated Web Content162,972,460,260,057,354,951,126,216,613,811,910,79,66,25,35,24,74,54,13,93,83,43,43,32,62,52,22,2CPU Time22:08:08,001:30:10,242:06:04,796:35,177:41:21,226:02,685:31:02,143:28:14,3631:02,405:39,492:52,5031:38,444:32,6755:50,9513:22,691:02:18,765:48,421:45:30,0816:38,5456:13,3039:59,162:26,3819:27,683:05,5412:52,602:23:16,7214:39,6829:02,54System:User:Idle:Threads ‹ >0 (hl100% C8•Thu 28 May 16:46:08CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,00,022,30,051,710,60,00,00,00,00,00,00,00,03,00,20,00,00,00,00,00,00,00,00,00,20,00,041,76%48,14%10,09%CPU LOADThreads:Processes:SearchGPU Time0,000,005:59,240,003:15:10,181:45,120,005:03,900,000,000,000,000,000,005:47,3117,580,000,040,000,000,000,000,040,000,0523:50,990,000,00PID Userrootlukaslukaslukas_windowserverlukaslukaslukasrootlukaslukaslukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukasrootlukaslukaslukaslukaslukaslukas...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86121
|
2955
|
22
|
2026-05-28T13:46:05.493415+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975965493_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20 rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentontCDecmrate-cmwonnDeleteObjectsTrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo# ProsnectSearchScope.phpUeerRokObserver.ohpyTeom.oneuseh3 Бclass Service extends BaseService nolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginby-nazuintertace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacskenorccner ylookoo ncertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate coner nt kiseie wiis eheyh*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.phpA SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaum045 A1 A41 У 66 A7o0sLX• Thu 28 May 16:46:05ServiceTesteeendhlesOrcherwichExolsiin to me nowSepurtunielatchActivityOn@rmOblectDetach listener. During this rematch, the system attempts tore-link the activity to the deleted opportunity, causing a foreign key constraint violation.The bug occurs in Salesforce: importOpportunity (and similar methods for Lead, Account, Contact):= Muo, un wwoeTht oatity oportuhy d. Couthe oredin hety rafedstersteit he oea toГОШЕОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)' ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE UUid_ to_bin ("[CREDIT_CARD]-9274-4f4da288185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*Eoon enn flalde whons id = 45022.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 12826-85-22' order by sid desclteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antéusty seanches whene usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02answehe coscorsro.unow.onotheowanoweniwtouroutthotorlemeshrchhtor.nerclcan..cooFast context RematchactivityOnCrmobicctDetachstener.in 972rast context importopportunity isbeletca handicob cctacicton in 8.065rast Context restoreAny.rashedtnlity in 8.00gAsk anything (XOL)c HodeAdhotv• OXtModtur Tatme Toxcsehireht4 spad...
|
NULL
|
4490911363058770911
|
NULL
|
visual_change
|
ocr
|
NULL
|
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20 rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentontCDecmrate-cmwonnDeleteObjectsTrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo# ProsnectSearchScope.phpUeerRokObserver.ohpyTeom.oneuseh3 Бclass Service extends BaseService nolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginby-nazuintertace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacskenorccner ylookoo ncertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate coner nt kiseie wiis eheyh*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.phpA SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaum045 A1 A41 У 66 A7o0sLX• Thu 28 May 16:46:05ServiceTesteeendhlesOrcherwichExolsiin to me nowSepurtunielatchActivityOn@rmOblectDetach listener. During this rematch, the system attempts tore-link the activity to the deleted opportunity, causing a foreign key constraint violation.The bug occurs in Salesforce: importOpportunity (and similar methods for Lead, Account, Contact):= Muo, un wwoeTht oatity oportuhy d. Couthe oredin hety rafedstersteit he oea toГОШЕОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)' ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE UUid_ to_bin ("[CREDIT_CARD]-9274-4f4da288185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*Eoon enn flalde whons id = 45022.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 12826-85-22' order by sid desclteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antéusty seanches whene usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02answehe coscorsro.unow.onotheowanoweniwtouroutthotorlemeshrchhtor.nerclcan..cooFast context RematchactivityOnCrmobicctDetachstener.in 972rast context importopportunity isbeletca handicob cctacicton in 8.065rast Context restoreAny.rashedtnlity in 8.00gAsk anything (XOL)c HodeAdhotv• OXtModtur Tatme Toxcsehireht4 spad...
|
86120
|
NULL
|
NULL
|
NULL
|
|
86120
|
2955
|
21
|
2026-05-28T13:45:56.290396+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975956290_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20 rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectsTrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseService inolesents.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyinterclprowdwewe ehr nong PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate coner nt kisei wiiis tnhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiServiceTestDeleteObjectsTrait.php© Client.phpTO0У L7Thu 28 May 16:45:56+0.A SF fiminny@localhost)console (PROD) x C Service.php# console fiaumA console [STAGING)D0000Ty: AutowГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;045 A1 A41 У 66 4* fron teans where id = 1;NCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 12826-85-22' order by sid desclteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02CascaolesOrchwrwichExolsiin to me nowThought for 2sT analyze the codebase to understand the tiow and verity your descnipbon. Let me scarch for the relevant codcto Fast context RematchactivityOncrmobiectbetachustene0Grannins RamatchlctiteAmob acthutnchld harttannohooho© Grepping CrmObjectDetach in /Users/lukas/jiminnylapp/apgGrepping RewFastContext.imoort@noortunity lsbeleted handicobicctioeletooreooin trporopportonay in osersnor ominhetA6 Fast Context restoreAnyTrashedEntittnmaed do yaegrtend io coaoet vi entatend in mrtene.io rtoneiry eoi age en tny thit a renat:- o 10d codbs 2o, LaOGrnonina Amirathad fotity in Mrace/ TkasfimiseuthoolneAsk anything (XOL)" PodnAdhotv• OXtModtur Tatme Toxcsehireht4 spag...
|
NULL
|
8429825714372213612
|
NULL
|
visual_change
|
ocr
|
NULL
|
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20 rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectsTrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseService inolesents.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyinterclprowdwewe ehr nong PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate coner nt kisei wiiis tnhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiServiceTestDeleteObjectsTrait.php© Client.phpTO0У L7Thu 28 May 16:45:56+0.A SF fiminny@localhost)console (PROD) x C Service.php# console fiaumA console [STAGING)D0000Ty: AutowГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;045 A1 A41 У 66 4* fron teans where id = 1;NCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896850248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 12826-85-22' order by sid desclteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty canoch fA TM CARRR2 RRO02CascaolesOrchwrwichExolsiin to me nowThought for 2sT analyze the codebase to understand the tiow and verity your descnipbon. Let me scarch for the relevant codcto Fast context RematchactivityOncrmobiectbetachustene0Grannins RamatchlctiteAmob acthutnchld harttannohooho© Grepping CrmObjectDetach in /Users/lukas/jiminnylapp/apgGrepping RewFastContext.imoort@noortunity lsbeleted handicobicctioeletooreooin trporopportonay in osersnor ominhetA6 Fast Context restoreAnyTrashedEntittnmaed do yaegrtend io coaoet vi entatend in mrtene.io rtoneiry eoi age en tny thit a renat:- o 10d codbs 2o, LaOGrnonina Amirathad fotity in Mrace/ TkasfimiseuthoolneAsk anything (XOL)" PodnAdhotv• OXtModtur Tatme Toxcsehireht4 spag...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86119
|
2955
|
20
|
2026-05-28T13:45:40.517986+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975940517_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6423983795013307969
|
-3852565242246887264
|
typing_pause
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.onpyTeom.oneuser.3 Бclass Service extends BaseService inolesents.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt hsei Wins teNeiheyih*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M Mai7o0sLX• Thu 28 May 16:45:40DeleteObjectsTrait.php© Client.phpA SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,045 A1 A41 У 66 AServiceTesteeendhlesOrcherwichwepurtunielatchActivityOn@rmOblectDetach listener. During this rematch, the system attempts toBoot Careaetviy to the dereted opportunty, causing a foreign key constraint violation.The bug occurs in Salesforce: importOpportunity (and similar methods for Lead, Account, Contact)ГОШЕОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE uuid_ to_bin ("[CREDIT_CARD]-927f-4f4da288185e') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*Eoon enn ffalde whans 1d = 450242-* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Ask anything (XoL)" PodnAdhotvXtModtur Tatme Toxcsehireh*4 space...
|
86117
|
NULL
|
NULL
|
NULL
|
|
86118
|
2954
|
24
|
2026-05-28T13:45:39.418092+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975939418_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
5582423643801155883
|
-8994321436789994556
|
typing_pause
|
hybrid
|
NULL
|
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
Activity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerscreenpipereplaydWindowServermds_storesFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentlaunchservicesdFirefoxcoreaudiodSlack HelperiTerm2cef_server Helper (Renderer)SlackActivity MonitorSlack Helper (Renderer)cef_server Helper (GPU)Wispr FlowWispr Flow Helper (Renderer)language_server_macos_armFirefox GPU Helpercef_serverFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentDocker198,3186,290,270,956,332,311,410,610,08,86,45,24,94,74,74,64,33,93,63,23,12,92,72,62,42,42,22,1CPU Time22:07:18,822:05:39,401:29:48,573:28:07,415:30:48,587:41:07,181:02:35,794:29,9331:35,7459:01,511:45:28,3655:49,3213:21,321:02:17,236:27,0712:52,255:47,0356:10,665:54,782:08,243:04,675:36,422:23:16,042:49,601:56,8314:38,9639:58,321:04,76System:User:Idle:Threads lahl100% <7Thu 28 May 16:45:39CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind % GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple49,41%45,21%5,38%0,07,70,00,00,045,50,00,00,00,00,00,04,50,40,00,00,00,00,90,00,00,01,90,00,00,00,00,0CPU LOADThreads:Processes:SearchGPU Time0,000,000,000,000,000,000,000,040,000,000,050,000,000,000,000,000,000,000,000,000,00PID Userrootlukaslukaslukaslukas_windowserverrootlukaslukasrootlukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukas...
|
86115
|
NULL
|
NULL
|
NULL
|
|
86117
|
2955
|
19
|
2026-05-28T13:45:37.198711+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975937198_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"bounds":{"left":0.35272607,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"bounds":{"left":0.3643617,"top":0.12529927,"width":0.012632979,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.37898937,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"bounds":{"left":0.38896278,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.40026596,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.40757978,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":4,"bounds":{"left":0.13863032,"top":0.070231445,"width":0.38597074,"height":0.92976856},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.41589096,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.4245346,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"bounds":{"left":0.43550533,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"bounds":{"left":0.44414893,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"bounds":{"left":0.45279256,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"bounds":{"left":0.4637633,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"bounds":{"left":0.47473404,"top":0.09896249,"width":0.024268618,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"bounds":{"left":0.5013298,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"bounds":{"left":0.51230055,"top":0.09896249,"width":0.029587766,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"bounds":{"left":0.7237367,"top":0.09896249,"width":0.02825798,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1556586073691613239
|
-7851921920897086523
|
typing_pause
|
accessibility
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86116
|
2955
|
18
|
2026-05-28T13:45:34.480003+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975934480_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6423983795013307969
|
-3852565242246887264
|
typing_pause
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiServiceTestDeleteObjectsTrait.php© Client.php• Thu 28 May 16:45:34+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoeleu.eeendhles orcesirwichAetew045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE uuid_ to_bin ("[CREDIT_CARD]-927f-4f4da288185e') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2926-05-91*-* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cachado Codo XoKick off a new oro© Fixcing and Validating TextRelayService TestsExplain to me how does this branch diff solves the issue with. ls this acurate description: When an opportunity isнне оно смани олотирои воену от ру олнани оаенуот орине.ormupis ore-lnktchedscurs in Salesforco:importOpportunity (and similar methods for Lead, Account, Contact)neoendsdieobjectdeletion (because IsDeleted = truedotactive: yale munityid, caueнало осо а су муьной е свиа со ово молty no onger erit in theXtModtur Tatme Toxcsehireh*4 space...
|
86113
|
NULL
|
NULL
|
NULL
|
|
86115
|
2954
|
23
|
2026-05-28T13:45:34.016691+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975934016_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1556586073691613239
|
-7851921920897086523
|
visual_change
|
accessibility
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86114
|
2954
|
22
|
2026-05-28T13:45:31.202499+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975931202_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8043719072324535154
|
-8628527368849355612
|
typing_pause
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JAlActivity MonitorFileEd Project: faVsco.js, menu
JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerscreenpipereplaydWindowServercef_server Helper (Renderer)FirefoxCP Isolated Web ContentFirefoxlaunchservicesdFirefoxCP Isolated Web ContentcoreaudiodSlackiTerm2Slack HelperActivity MonitorSlack Helper (Renderer)tccdlanguage_server_macos_armcef_server Helper (GPU)Wispr Flow Helper (Renderer)FirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentWispr FlowFirefox GPU Helpersyspolicyd207,2149,087,677,648,736,614,210,19,89,28,76,25,85,04,84,24,03,23,23,13,13,02,92,82,82,82,52,5CPU Time22:06:58,072:05:23,281:29:37,203:28:01,205:30:43,317:41:03,476:26,6431:34,751:45:27,6659:01,034:28,8655:48,7712:51,981:02:16,7013:20,825:46,5756:09,918:24,025:36,075:54,443:04,3416:37,1839:57,8422:10,6114:38,692:07,942:23:15,7716:21,54System:User:Idle:Threads lahl100% <7Thu 28 May 16:45:30CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind % GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple50,18%43,71%6,11%0,06,80,00,00,045,00,00,00,00,00,00,00,00,44,00,00,00,00,00,60,00,00,00,00,00,03,10,0CPU LOADThreads:Processes:SearchGPU Time3:1PID Userrootlukaslukaslukaslukas_windowserverlukaslukaslukasrootlukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukasroot...
|
86111
|
NULL
|
NULL
|
NULL
|
|
86113
|
2955
|
17
|
2026-05-28T13:45:31.080079+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975931080_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
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...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7617337236921633572
|
-8635159314967324256
|
typing_pause
|
hybrid
|
NULL
|
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
rapstomViewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.onpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiServiceTestDeleteObjectsTrait.php© Client.php• Thu 28 May 16:45:30+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwichAetew045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE uuid_ to_bin ("[CREDIT_CARD]-927f-4f4da288185e') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick off a new oro© Fixcing and Validating TextRelayService TestsExplain to me how does this branch diff solves the issue with. ls this acurate description: When an opportunity isormupis ore-lnktchedscurs in Salesforco:importOpportunity (and similar methods for Lead, Account, Contact):sforce returns a record with isDeleted = true, the code:neoendsdieobjectdeletion (because IsDeleted = trueволнну . самато со n сy мру uнос e etиa ca o tunty n longer evits i tha.XtModtur Tatme Toxcsehireh*4 space...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86112
|
2955
|
16
|
2026-05-28T13:45:30.115215+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975930115_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"bounds":{"left":0.35272607,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"bounds":{"left":0.3643617,"top":0.12529927,"width":0.012632979,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.37898937,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"bounds":{"left":0.38896278,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.40026596,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.40757978,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":4,"bounds":{"left":0.13863032,"top":0.070231445,"width":0.38597074,"height":0.92976856},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.41589096,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1556586073691613239
|
-7851921920897086523
|
typing_pause
|
accessibility
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
86109
|
NULL
|
NULL
|
NULL
|
|
86111
|
2954
|
21
|
2026-05-28T13:45:30.014325+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975930014_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"45","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"41","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"66","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993\nSELECT * FROM users WHERE id = 25061;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 994;\nSELECT * FROM crm_profiles WHERE user_id = 25061;\n\nselect * from crm_configurations where id = 834;\nSELECT * FROM teams WHERE id = 882;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;\n\nSELECT * FROM contacts where crm_configuration_id = 834;\nSELECT * FROM opportunities WHERE team_id = 933\n# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');\nAND id IN (8482561,18352941,19042734,19232139,19445140,19472541);\nSELECT * FROM opportunity_contacts\nWHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; #\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nselect crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id\nwhere crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')\n# and l.converted_at IS NOT NULL\n;\n\n# ********************************************************************\nSELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')\nand opportunity_id IS NULL\norder by id desc;\n\nSELECT * FROM teams WHERE id = 604; # 598\nSELECT * FROM activities WHERE id = 74410828; # chelseaw@allvoices.co\nSELECT * FROM accounts WHERE id = 20068382;\nSELECT * FROM accounts WHERE id = 35186038;\n\nSELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 559 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;\nselect * from sidekick_settings where team_id = 781;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 711;\nSELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL\nand is_internal = 0 and status = 'completed'\norder by id desc;\n\nSELECT * FROM crm_layout_entities\nWHERE crm_layout_id IN (2352, 2353);\n;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;\nSELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;\nselect * from contacts\nwhere crm_configuration_id = 530\nand crm_provider_id = 872252;\n\nselect * from activities where crm_configuration_id = 530\nand user_id = 14343 and type like '%softphone%'\nand created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);\n\n\nSELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t\nJOIN crm_configurations c ON t.id = c.team_id\nWHERE t.status = 'active';\n\nSELECT * FROM teams where id = 1091;\nSELECT * FROM crm_configurations where team_id = 1091;\nSELECT * FROM activity_providers where team_id = 1091;\nSELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT * FROM teams WHERE name LIKE '%Leadventure%';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1091 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812\nSELECT * FROM teams where id = 862;\nSELECT * FROM crm_configurations where team_id = 862;\nSELECT * FROM activity_providers where team_id = 862;\nSELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT t.id, crm.id, crm.provider, ap.* FROM teams t\njoin crm_configurations crm on t.id = crm.team_id\njoin activity_providers ap on t.id = ap.team_id\nwhere t.status = 'active' and ap.is_enabled = 1\nand crm.provider = 'hubspot'\nand ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',\n 'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');\n\nSELECT * FROM teams where id = 1068;\nSELECT * FROM crm_configurations where team_id = 1068;\nSELECT * FROM activity_providers where team_id = 1068;\n\nSELECT * FROM activities a\nwhere crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')\nand a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'\n )\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by a.id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1068 and sa.provider = 'hubspot';\n\n# ********************************************************************\n# ********************************************************************\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262\nSELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\nselect * from crm_layouts where crm_configuration_id = 834;\nselect * from crm_layout_entities where crm_layout_id = 2780;\nselect * from crm_fields where id IN (321153,321192,321193,321194);\n\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1057 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8\n\nSELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20\n\nSELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10\n\nSELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #\n\nSELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;\nselect * from users where team_id = 51; # 7783\nSELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130\nselect * from activity_searches where user_id = 7783;\nselect * from activity_search_filters where activity_search_id IN (32291, 32292);\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th\n# ********************************************************************\nSELECT * FROM crm_configurations where provider = 'hubspot';\nSELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133\nSELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null\n# ********************************************************************\n\nselect * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';\nselect\n cp.*\n# DISTINCT t.id\n# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields\nFROM crm_profiles cp\nJOIN crm_configurations crm on crm.id = cp.crm_configuration_id\nJOIN users u on u.id = cp.user_id\nJOIN teams t ON t.id = crm.team_id\nWHERE crm.provider = 'salesforce' and t.status = 'active'\n and cp.archived_at IS NULL and u.deleted_at IS NULL\n and t.id NOT IN (1093)\n and t.id = 2\n and cp.contact_fields IS NULL;\n# and c.crm_provider_id = '003Uu00000ojD4NIAU';\n\nSELECT * FROM users WHERE id = 26484;\nSELECT * FROM crm_profiles WHERE user_id = 26484;\nSELECT * FROM social_accounts WHERE sociable_id = 26484;\nSELECT * FROM crm_configurations where provider = 'salesforce';\nselect * from users where id IN (10022, 10403);\nselect * from users where team_id IN (526);\nselect * from teams where id IN (526, 532);\nselect * from crm_configurations where id IN (500, 516);\nselect * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);\nselect * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 526 and sa.provider = 'salesforce';\nselect * from team_settings where team_id IN (526, 532);\n\nselect * from users where id IN (22824);\nselect * from crm_profiles where crm_configuration_id IN (1026);\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1093 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1099;\nselect * from users where id = 29643\n\nselect * from activity_processing_states;\n\nSELECT * FROM teams where name LIKE '%Fare%'; # 233\nSELECT * FROM opportunities where crm_configuration_id = 215\n# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'\n;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1088 and sa.provider = 'hubspot';\n\nSELECT * FROM teams order by updated_at DESC\nSELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account\n\nselect * from crm_configurations where provider = 'pipedrive';\n\nselect * from teams where id = 957;\nselect * from crm_configurations where id = 957;\n\nSELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743\nSELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;\n\nselect * from users where team_id = 1; # 26726 - Gabriela Dureva\nSELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific\nselect * from activities where user_id = 26726 order by id desc;\nselect * from contacts where crm_configuration_id = 1\nand email IN ('charlotte.ward@prolific.com', 'frankie.bryant@prolific.com'); # 2094416, 2093620\nSELECT * FROM contacts WHERE id = 6284931;\n\nSELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id\nWHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;\n\nselect * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);\nselect * from crm_configurations where id = 1;\n\n43801692-1aeb-32ce-acba-5b80a479701a\n44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b\n405975c0-b3d0-7aaa-821f-09d59cae6dd1\n4caf848d-4bed-2299-b248-7788d41f9fca\n49bedc3f-f196-eef3-89c3-dea6a3b4aa63\n43420989-a09d-b8f8-9806-c8bbf7a02aac\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nSELECT * FROM activities WHERE id = 75461988;\n\nSELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;\n\nselect * from contacts where id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nselect * from users where id = 21047;\nSELECT * FROM crm_configurations WHERE id = 892;\nSELECT * FROM teams WHERE id = 942;\nselect * from opportunities where team_id = 942 order by updated_at desc;\nselect * from contacts where team_id = 942 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 942 and sa.provider = 'hubspot';\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430\nSELECT * FROM crm_configurations WHERE id = 1;\nSELECT * FROM teams WHERE crm_id = 1;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1\nSELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430\n\nselect * from teams where id = 852;\nselect * from groups where id = 2286;\nselect * from sidekick_settings where team_id = 852;\nselect * from default_activity_types where team_id = 852;\n\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1 AND u.deleted_at IS NULL\nAND u.crm_required = 1\nAND u.team_id = 1\nORDER BY u.team_id;\n\nSELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (\n18481\n );\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1\n AND u.deleted_at IS NULL\n AND u.crm_required = 1\n# AND u.team_id = 1\n AND p.id IS NULL -- Move this condition to WHERE clause\nORDER BY u.team_id;\n\nSELECT * FROM opportunities WHERE id = 20002609;\nselect * from teams where id = 1122; # Velatir, 29953 - christian@velatir.com\nselect * from crm_configurations where id = 1060;\nselect * from crm_layouts where crm_configuration_id = 1060;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1122 and sa.provider = 'hubspot';\nselect * from opportunities where team_id = 1122 order by updated_at desc;\n\nselect * from crm_field_data where object_type = 'contact';\n\nSELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 248 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS\nSELECT * FROM users where id = 24115;\nSELECT * FROM accounts where id = 4002896;\nSELECT * FROM teams WHERE name LIKE '%adswerve%';\nSELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN (\"0069N000003GIQ9QAO\",\"0061r000019yGP9AAM\",\"0066900001S2KWlAAN\",\"0066900001TDpj2AAD\",\"0066900001b8uEwAAI\",\"0069N000001rQi0QAE\",\"006QF00000KD40mYAD\",\"006QF00000LzpRJYAZ\",\"0069N000002uomtQAA\",\"0069N000002xlMLQAY\",\"0066900001NV6ubAAD\",\"0061r00001HJp45AAD\",\"006QF00000uTlUoYAK\",\"006QF00000v0bZqYAI\");\nSELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203\n\nSELECT u.id, u.email, ac.name, a.* FROM activities a\nJOIN users u ON a.user_id = u.id\nJOIN accounts ac ON a.account_id = ac.id\nWHERE\nuuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or\nuuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or\nuuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;\n\nselect * from users where id = 5825;\nSELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;\n\nselect * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;\n19594, 862\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 862 and sa.provider = 'salesforce';\n\nselect * from automated_reports where id = 36;\nselect ar.frequency, r.*, ar.* from automated_report_results r\njoin automated_reports ar on r.report_id = ar.id\nwhere ar.frequency != 'one_off';\n\nselect s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;\nselect * from nudges n where n.activity_search_id\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;\n\nselect * from users where team_id = 1 and name like '%Lukas%'; # 7160\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\nSELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,\nselect * from opportunities where team_id = 1126;\nSELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,\nselect * from opportunities where team_id = 1125;\nselect * from contacts c\nwhere c.team_id = 882;\n\nSELECT * FROM activities WHERE id = 76822967;\nSELECT * FROM crm_profiles WHERE user_id = 15440;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 555;\nSELECT * FROM crm_configurations WHERE id = 555;\nSELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 581 and sa.provider = 'salesforce';\n\nSELECT * FROM automated_report_results order by id desc;\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556;\n\nselect * from automated_reports;\nwhere id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , [\"pdf\",\"podcast\"]\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\nselect * from automated_report_results order by id desc;\nSELECT * FROM automated_report_results WHERE id = 1919;\n\nselect * from automated_report_results WHERE report_id = 54;\n\nselect * from opportunities where id = 7594349;\n\nSELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - jiminnyintegration@lesmills.com\nselect * from playbooks where team_id = 711; # event 226147\nSELECT * FROM playbook_categories WHERE playbook_id = 5515;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';\nSELECT * FROM crm_fields WHERE id = 226147;\nSELECT * FROM crm_field_values WHERE crm_field_id = 226147;\n\nSELECT * FROM crm_configurations WHERE id = 692;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 711 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;\n\nselect * from leads;\n\nselect * from calendars;\n\nSELECT\n t.id AS team_id,\n t.name,\n LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain\nFROM teams t\nJOIN users u ON u.team_id = t.id\nJOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'\nLEFT JOIN team_domains td\n ON td.team_id = t.id\n AND td.deleted_at IS NULL\n AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))\nGROUP BY t.id, t.name, calendar_domain\nORDER BY t.name, calendar_domain;\n\nselect * from users u join calendars c on c.user_id = u.id\nwhere u.team_id = 882;\n\n\nselect * from activities where id = 74049485; # team 563 crm 537\nselect * from activities where id = 73272382; # team 563 crm 537\nselect * from activities where id = 64400389; # team 563 crm 537\nselect * from activities where id = 58081273; # team 563 crm 537\nselect * from activities where id = 54520297; # team 563 crm 537\nselect * from participants where activity_id = 58081273;\n\nselect * from activities where crm_configuration_id = 537 and provider = 'aircall'\nand account_id = 19003658 order by updated_at desc;\n\nselect * from contacts where crm_configuration_id = 537 and id = 35957759;\nselect * from accounts where crm_configuration_id = 537 and id = 19003658;\n\nselect * from automated_report_results where id = 1976;\nselect * from automated_reports where id = 583;\nselect * from activity_searches where id = 87714;\nselect * from activity_search_filters where activity_search_id = 87714;\n\nSELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid\nor uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot';\nselect * from rate_limits;\n\nselect * from automated_report_results where media_type = 'pdf' and status = 2\nand id IN (18, 1872);\nselect * from automated_reports where id = 54;\nSELECT * FROM users WHERE id IN (24623,29443,29613);\n\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\n\nselect * from text_relays where created_at > '2026-05-01';\nand id IN (32415, 32416);\n# and id = 32412;\n\nselect * from users where team_id = 2 and email like '%scott%' and id = 29510;\n\nSELECT * FROM activities WHERE uuid_to_bin('67cebfc2-ed56-44a2-8c68-7a0286ed8618') = uuid; # 79763436\n\nSELECT email_provider_id, COUNT(*) as count, GROUP_CONCAT(id) as ids, GROUP_CONCAT(status) as statuses\nFROM text_relays\nWHERE email_provider_id IN ('19e2027868a64b42', '19e2033ed8ea6b10')\nGROUP BY email_provider_id;\nSELECT id, status, telephony_provider_id, created_at\nFROM activities\nWHERE id IN (80028719, 80028846);\nSELECT id, status, code, email_sent_at, created_at, updated_at\nFROM text_relays\nWHERE id IN (32415, 32416);\nSELECT id, status, code, sender, recipient, created_at\nFROM text_relays\nWHERE sender LIKE '%mario.georgiev%' OR sender LIKE '%stoyan.tomov%'\nORDER BY created_at DESC\nLIMIT 10;\n\nSELECT id, uuid, status, code, sender, recipient, created_at, updated_at\nFROM text_relays\nWHERE uuid = uuid_to_bin('0626141c-27a6-4d8c-aff8-7c8020a2c656');\n\n# ***************\nSELECT DISTINCT u.id, u.email, u.name, u.softphone_number, COUNT(a.id) as sms_count\nFROM users u\nINNER JOIN activities a ON u.id = .user_id\nWHERE a.type LIKE 'sms%'\nAND a.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)\nGROUP BY u.id, u.email, u.name, u.softphone_number\nORDER BY sms_count DESC;\n\nselect * from teams where id = 1;\n\nselect * from roles;\n\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1117 and sa.provider = 'hubspot';\nSELECT * FROM activities WHERE uuid_to_bin('8024fffb-2df7-4017-91f4-d9f896050248') = uuid; # 79933459 YES\nSELECT * FROM activities WHERE uuid_to_bin('25529043-8094-4781-927f-4f4da2a8185c') = uuid; # 80186192 NO\nSELECT * FROM crm_configurations WHERE id = 1053;\nSELECT * FROM teams WHERE id = 1117;\nselect * from users where id = 30249;\nselect * from playbooks where id = 5473;\nselect * from playbook_categories where id = 43783;\nselect * from playbook_categories where playbook_id = 5473;\nselect * from crm_fields where id = 659242;\nselect * from crm_field_values where crm_field_id = 659242;\n\nSELECT * FROM crm_field_data fd\n# JOIN crm_fields f ON fd.crm_field_id = f.id\n# JOIN activities a ON fd.activity_id = a.id\nWHERE activity_id = 79933459\n# AND f.crm_provider_id = 'hs_activity_type';\n\n\nSELECT * FROM activity_messages;\nselect * from text_relays where created_at > '2026-05-01';\nselect * from activities where user_id IN (7160, 18608) and created_at > '2026-05-22' order by id desc;\nselect * from users where team_id = 1 and id IN (18608, 13934, 7160);\nselect * from activities where user_id = 7160 order by id desc limit 10;\n\nselect * from accounts where team_id = 1 and name = 'Column5';\n\nselect * from users where name like '%Subra%'; # 31054, 1117\nselect * from teams where id = 1117;\nselect * from activity_searches where user_id = 31054;\nselect * from activity_search_filters where activity_search_id IN (88882, 88902);","depth":4,"on_screen":true,"value":"SELECT * FROM teams WHERE name LIKE '%litify%'; # 1069, 994, 24993\nSELECT * FROM users WHERE id = 25061;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 994;\nSELECT * FROM crm_profiles WHERE user_id = 25061;\n\nselect * from crm_configurations where id = 834;\nSELECT * FROM teams WHERE id = 882;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot' and crm_provider_id = 7270388;\n\nSELECT * FROM contacts where crm_configuration_id = 834;\nSELECT * FROM opportunities WHERE team_id = 933\n# AND crm_provider_id IN ('20131586060','46017317898','52543911090','53451356564','54101251892','54323768459');\nAND id IN (8482561,18352941,19042734,19232139,19445140,19472541);\nSELECT * FROM opportunity_contacts\nWHERE opportunity_id IN (8482561,18352941,19042734,19232139,19445140,19472541);\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 485; #\nSELECT * FROM opportunities WHERE team_id = 933 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\nselect crm.provider, l.* from leads l join crm_configurations crm on l.crm_configuration_id = crm.id\nwhere crm.provider NOT IN ('salesforce', 'integration-app', 'bullhorn', 'copper')\n# and l.converted_at IS NOT NULL\n;\n\n# ********************************************************************\nSELECT * FROM activities a WHERE type IN ('email-inbound', 'email-outbound')\nand opportunity_id IS NULL\norder by id desc;\n\nSELECT * FROM teams WHERE id = 604; # 598\nSELECT * FROM activities WHERE id = 74410828; # chelseaw@allvoices.co\nSELECT * FROM accounts WHERE id = 20068382;\nSELECT * FROM accounts WHERE id = 35186038;\n\nSELECT * FROM contacts WHERE team_id = 852 and updated_at > '2026-01-23 12:30:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 559 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('cb6342b6-a183-401c-b0af-ede92b2ae763') = uuid;\nselect * from sidekick_settings where team_id = 781;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 26651871; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 7562435;\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8420347; # opflit 2100\n\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 711;\nSELECT * FROM activities where crm_configuration_id = 711 and crm_provider_id IS NULL\nand is_internal = 0 and status = 'completed'\norder by id desc;\n\nSELECT * FROM crm_layout_entities\nWHERE crm_layout_id IN (2352, 2353);\n;\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and id = 530;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 556 and sa.provider = 'hubspot';\n\nSELECT * FROM activities WHERE uuid_to_bin('c6ca4b22-7738-4563-a95d-b8a9598924ae') = uuid;\nSELECT * FROM activities WHERE uuid_to_bin('442abb2b-28bd-4be8-9c25-19e9bf02766d') = uuid;\nselect * from contacts\nwhere crm_configuration_id = 530\nand crm_provider_id = 872252;\n\nselect * from activities where crm_configuration_id = 530\nand user_id = 14343 and type like '%softphone%'\nand created_at between '2026-01-28 15:00:00' and '2026-01-28 15:10:00';\n\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 25666868; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id = 8646335; # Teya\nSELECT * FROM crm_configurations where provider = 'hubspot' and crm_provider_id IN (5933397);\n\n\nSELECT t.name, t.id, t.owner_id, c.id, c.provider, c.crm_base_url FROM teams t\nJOIN crm_configurations c ON t.id = c.team_id\nWHERE t.status = 'active';\n\nSELECT * FROM teams where id = 1091;\nSELECT * FROM crm_configurations where team_id = 1091;\nSELECT * FROM activity_providers where team_id = 1091;\nSELECT * FROM activities where crm_configuration_id = 1024 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT * FROM teams WHERE name LIKE '%Leadventure%';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1091 and sa.provider = 'salesforce';\n\nSELECT * FROM teams WHERE name LIKE '%Wilson%'; # 862, 812\nSELECT * FROM teams where id = 862;\nSELECT * FROM crm_configurations where team_id = 862;\nSELECT * FROM activity_providers where team_id = 862;\nSELECT * FROM activities where crm_configuration_id = 812 and type IN ('softphone', 'softphone-outbound')\nand provider NOT IN ('hubspot', 'aircall')\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by id desc;\n\n\nSELECT t.id, crm.id, crm.provider, ap.* FROM teams t\njoin crm_configurations crm on t.id = crm.team_id\njoin activity_providers ap on t.id = ap.team_id\nwhere t.status = 'active' and ap.is_enabled = 1\nand crm.provider = 'hubspot'\nand ap.provider NOT IN ('hubspot', 'aircall', 'uploader', 'gong', 'twilio', 'zoom-bot', 'google-meet', 'ms-teams',\n 'outreach', 'close', 'ringcentral', 'dialpad', 'zoom-phone');\n\nSELECT * FROM teams where id = 1068;\nSELECT * FROM crm_configurations where team_id = 1068;\nSELECT * FROM activity_providers where team_id = 1068;\n\nSELECT * FROM activities a\nwhere crm_configuration_id = 993 and type IN ('softphone', 'softphone-outbound')\nand a.provider NOT IN ('hubspot', 'uploader', 'gong', 'twilio', 'google-meet', 'ms-teams','close'\n )\n# and telephony_provider_id = '019c1131-a22f-4792-b9ea-20adf6a02ed0'\norder by a.id desc;\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1068 and sa.provider = 'hubspot';\n\n# ********************************************************************\n# ********************************************************************\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 882; # 933 - GoGlobal , portalId: 6017093\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 933 and updated_at > '2026-02-06 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 933 and sa.provider = 'hubspot';\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 834; # 882 - AnyVan , portalId: 5468262\nSELECT * FROM contacts WHERE crm_configuration_id = 834 and updated_at > '2026-03-30 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and updated_at > '2026-03-04 08:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 882 and sa.provider = 'hubspot';\nselect * from crm_layouts where crm_configuration_id = 834;\nselect * from crm_layout_entities where crm_layout_id = 2780;\nselect * from crm_fields where id IN (321153,321192,321193,321194);\n\nSELECT * FROM opportunities WHERE crm_configuration_id = 834 and id = 10993426;\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 988; # 1057 - Teya (543ce4f4-168c-4571-91ea-5b35c253f06f) , portalId: 26651871\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1057 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1057 and sa.provider = 'hubspot';\n\nSELECT * FROM crm_configurations where id = 533; # 559 - Connectd , portalId: 6710988\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 559 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 801; # 852 - Rise Vision , portalId: 2700250\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 852 and updated_at > '2026-02-04 00:00:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 962; # 1034 - evergrowth.io , portalId: 143180990\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1034 and updated_at > '2026-02-04 00:00:00' order by updated_at desc;\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 1037; # 1102 - Jibble , portalId: 6649755\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1102 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 8\n\nSELECT * FROM crm_configurations where id = 1015; # 1049 - Travefy , portalId: 48904401\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1049 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 20\n\nSELECT * FROM crm_configurations where id = 64; # 70 - SalaryFinance , portalId: 3404115\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 70 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 6th last\n\nSELECT * FROM crm_configurations where id = 802; # 853 - Street Group , portalId: 7658438\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 853 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 10\n\nSELECT * FROM crm_configurations where id = 872; # 921 - In Professional Development , portalId: 9238273\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 921 and updated_at > '2026-02-04 12:30:00' order by updated_at desc; # 2\n\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 550; # 576 - SeedLegals , portalId: 3028661\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 576 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 989; # 1058 - rtaoutdoor.com , portalId: 22371204\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1058 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 896; # 946 - Mintago , portalId: 6621281\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 946 and updated_at > '2026-02-05 14:00:00' order by updated_at desc;\n\nSELECT * FROM crm_configurations where id = 617; # 641 - PCS , portalId: 5244937\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 641 and updated_at > '2026-02-05 14:00:00' order by updated_at desc; # 7th\n# ********************************************************************\nSELECT * FROM crm_configurations where id = 649; # 670 - Eventeny , portalId: 4492849\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-18 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 670 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; #\n\nSELECT * FROM crm_configurations where id = 48; # 51 - CleanCloud , portalId: 4373137\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-03-04 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 51 and updated_at > '2026-02-09 08:00:00' order by updated_at desc;\nselect * from users where team_id = 51; # 7783\nSELECT * FROM groups WHERE uuid_to_bin('8a8d2cb6-8b55-4fa3-8b5c-5f0e3d8de59a') = uuid; # 1130\nselect * from activity_searches where user_id = 7783;\nselect * from activity_search_filters where activity_search_id IN (32291, 32292);\n\nSELECT asf.activity_search_id, asf.id, asf.value\nFROM activity_search_filters asf\nWHERE asf.filter = 'group_id'\nAND asf.value IN (\n SELECT CONCAT(\n HEX(SUBSTR(uuid, 5, 4)), '-',\n HEX(SUBSTR(uuid, 3, 2)), '-',\n HEX(SUBSTR(uuid, 1, 2)), '-',\n HEX(SUBSTR(uuid, 9, 2)), '-',\n HEX(SUBSTR(uuid, 11))\n )\n FROM groups\n WHERE deleted_at IS NOT NULL\n);\n\nSELECT * FROM crm_configurations where id = 272; # 290 - Bonham & Brook , portalId: 5705856\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-05 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 290 and updated_at > '2026-02-09 08:00:00' order by updated_at desc; # 6th\n# ********************************************************************\nSELECT * FROM crm_configurations where provider = 'hubspot';\nSELECT * FROM crm_configurations where id = 1056; # 1119 - Chromatic , portalId: 45602133\nSELECT * FROM opportunities WHERE team_id = 1119 and remotely_created_at > '2026-02-01 00:00:00' order by updated_at desc;\nSELECT * FROM opportunities WHERE team_id = 1119 and updated_at > '2026-02-09 09:00:00' order by updated_at desc; # null\n# ********************************************************************\n\nselect * from contacts where crm_provider_id = '003Uu00000ojD4NIAU';\nselect\n cp.*\n# DISTINCT t.id\n# cp.id, cp.user_id, t.id, cp.crm_configuration_id, cp.contact_fields\nFROM crm_profiles cp\nJOIN crm_configurations crm on crm.id = cp.crm_configuration_id\nJOIN users u on u.id = cp.user_id\nJOIN teams t ON t.id = crm.team_id\nWHERE crm.provider = 'salesforce' and t.status = 'active'\n and cp.archived_at IS NULL and u.deleted_at IS NULL\n and t.id NOT IN (1093)\n and t.id = 2\n and cp.contact_fields IS NULL;\n# and c.crm_provider_id = '003Uu00000ojD4NIAU';\n\nSELECT * FROM users WHERE id = 26484;\nSELECT * FROM crm_profiles WHERE user_id = 26484;\nSELECT * FROM social_accounts WHERE sociable_id = 26484;\nSELECT * FROM crm_configurations where provider = 'salesforce';\nselect * from users where id IN (10022, 10403);\nselect * from users where team_id IN (526);\nselect * from teams where id IN (526, 532);\nselect * from crm_configurations where id IN (500, 516);\nselect * from crm_profiles where crm_configuration_id IN (500, 516) and user_id IN (10022, 10403);\nselect * from contacts where crm_configuration_id IN (500, 516) and crm_provider_id = '003Uu00000ojD4NIAU';\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 526 and sa.provider = 'salesforce';\nselect * from team_settings where team_id IN (526, 532);\n\nselect * from users where id IN (22824);\nselect * from crm_profiles where crm_configuration_id IN (1026);\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1093 and sa.provider = 'salesforce';\n\nselect * from teams where id = 1099;\nselect * from users where id = 29643\n\nselect * from activity_processing_states;\n\nSELECT * FROM teams where name LIKE '%Fare%'; # 233\nSELECT * FROM opportunities where crm_configuration_id = 215\n# and crm_provider_id = 'oppo_ogESZf2P50nDrd1nGPvKDXeA6sSaTN5v51Lp4ayVzKR'\n;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1088 and sa.provider = 'hubspot';\n\nSELECT * FROM teams order by updated_at DESC\nSELECT * FROM crm_configurations WHERE id = 1019; # SimpleConsign 1088 - no social account\n\nselect * from crm_configurations where provider = 'pipedrive';\n\nselect * from teams where id = 957;\nselect * from crm_configurations where id = 957;\n\nSELECT * FROM teams WHERE name LIKE '%Prolific%'; # 544, 518, 10743\nSELECT * FROM opportunities where crm_configuration_id = 518 order by id desc;\n\nselect * from users where team_id = 1; # 26726 - Gabriela Dureva\nSELECT * FROM opportunities where user_id = 26726; # 16834447 - Prolific\nselect * from activities where user_id = 26726 order by id desc;\nselect * from contacts where crm_configuration_id = 1\nand email IN ('charlotte.ward@prolific.com', 'frankie.bryant@prolific.com'); # 2094416, 2093620\nSELECT * FROM contacts WHERE id = 6284931;\n\nSELECT p.* FROM activities a JOIN participants p ON a.id = p.activity_id\nWHERE a.user_id = 26726 and p.lead_id IN (2094416, 2093620) and a.created_at > '2026-01-01 00:00:00' order by p.email;\n\nselect * from activities where id IN (75509259,75509261,75509261,75511034,75026464,75517602,75517605);\nselect * from crm_configurations where id = 1;\n\n43801692-1aeb-32ce-acba-5b80a479701a\n44c3c9cf-6f5e-75f3-8179-bc9f75dd2b1b\n405975c0-b3d0-7aaa-821f-09d59cae6dd1\n4caf848d-4bed-2299-b248-7788d41f9fca\n49bedc3f-f196-eef3-89c3-dea6a3b4aa63\n43420989-a09d-b8f8-9806-c8bbf7a02aac\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nSELECT * FROM activities WHERE id = 75461988;\n\nSELECT * FROM activities WHERE uuid_to_bin('d6c5052e-e972-49e9-8912-26f2f7d6c5f6') = uuid;\n\nselect * from contacts where id = 17900517;\n\nselect * from contact_roles cr join crm_configurations crm on cr.crm_configuration_id = crm.id\nwhere crm.provider != 'salesforce';\n\nselect * from users where id = 21047;\nSELECT * FROM crm_configurations WHERE id = 892;\nSELECT * FROM teams WHERE id = 942;\nselect * from opportunities where team_id = 942 order by updated_at desc;\nselect * from contacts where team_id = 942 order by updated_at desc;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 942 and sa.provider = 'hubspot';\n\nSELECT * FROM opportunities where team_id = 1 and crm_provider_id IN ('006Pq00000NeH6XIAV', '006Pq000007z8kdIAA'); # 10697889, 6621430\nSELECT * FROM crm_configurations WHERE id = 1;\nSELECT * FROM teams WHERE crm_id = 1;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1 and sa.provider = 'salesforce';\n\nselect id, user_id, opportunity_fields from crm_profiles where crm_configuration_id = 1\nSELECT * FROM opportunities where team_id = 1 order by updated_at desc; # 10697889, 6621430\n\nselect * from teams where id = 852;\nselect * from groups where id = 2286;\nselect * from sidekick_settings where team_id = 852;\nselect * from default_activity_types where team_id = 852;\n\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id AND p.id IS NULL -- no profile\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active' -- team is active\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1 AND u.deleted_at IS NULL\nAND u.crm_required = 1\nAND u.team_id = 1\nORDER BY u.team_id;\n\nSELECT * FROM crm_profiles cp where cp.crm_configuration_id = 1 and cp.user_id IN (\n18481\n );\n\nSELECT cc.provider, cc.id, p.id, u.*\nFROM users u\nLEFT JOIN crm_profiles p ON u.id = p.user_id\nINNER JOIN teams t ON u.team_id = t.id AND t.status = 'active'\nINNER JOIN crm_configurations cc ON t.crm_id = cc.id\nWHERE u.status = 1\n AND u.deleted_at IS NULL\n AND u.crm_required = 1\n# AND u.team_id = 1\n AND p.id IS NULL -- Move this condition to WHERE clause\nORDER BY u.team_id;\n\nSELECT * FROM opportunities WHERE id = 20002609;\nselect * from teams where id = 1122; # Velatir, 29953 - christian@velatir.com\nselect * from crm_configurations where id = 1060;\nselect * from crm_layouts where crm_configuration_id = 1060;\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3596;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1122 and sa.provider = 'hubspot';\nselect * from opportunities where team_id = 1122 order by updated_at desc;\n\nselect * from crm_field_data where object_type = 'contact';\n\nSELECT * FROM activities WHERE uuid_to_bin('374fc8ed-3315-4c9f-9b25-318b7fd2928f') = uuid; # 76584262\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 248 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles where user_id = 24115; # 005QF000002CswMYAS\nSELECT * FROM users where id = 24115;\nSELECT * FROM accounts where id = 4002896;\nSELECT * FROM teams WHERE name LIKE '%adswerve%';\nSELECT * FROM opportunities where crm_configuration_id = 230 AND crm_provider_id IN (\"0069N000003GIQ9QAO\",\"0061r000019yGP9AAM\",\"0066900001S2KWlAAN\",\"0066900001TDpj2AAD\",\"0066900001b8uEwAAI\",\"0069N000001rQi0QAE\",\"006QF00000KD40mYAD\",\"006QF00000LzpRJYAZ\",\"0069N000002uomtQAA\",\"0069N000002xlMLQAY\",\"0066900001NV6ubAAD\",\"0061r00001HJp45AAD\",\"006QF00000uTlUoYAK\",\"006QF00000v0bZqYAI\");\nSELECT * FROM opportunities WHERE crm_configuration_id = 230 AND crm_provider_id = '0069N000003GIQ9QAO'; # 6272203\n\nSELECT u.id, u.email, ac.name, a.* FROM activities a\nJOIN users u ON a.user_id = u.id\nJOIN accounts ac ON a.account_id = ac.id\nWHERE\nuuid_to_bin('e3269598-b562-44fb-b5e9-9d2694dc63e0') = a.uuid or\nuuid_to_bin('66ddc3ab-4e15-45aa-af0c-248c1eece593') = a.uuid or\nuuid_to_bin('826bd328-e1cc-4213-b8d8-572454cacc07') = a.uuid;\n\nselect * from users where id = 5825;\nSELECT * FROM activities WHERE uuid_to_bin('e56aa2e8-231a-421b-ab1f-cb38ed2bf573') = uuid;\n\nselect * from activities where uuid_to_bin('91e13b2f-2d1b-45f8-b1fd-1141b6563782') = uuid;\n19594, 862\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 862 and sa.provider = 'salesforce';\n\nselect * from automated_reports where id = 36;\nselect ar.frequency, r.*, ar.* from automated_report_results r\njoin automated_reports ar on r.report_id = ar.id\nwhere ar.frequency != 'one_off';\n\nselect s.* from activity_searches s join users u ON s.user_id = u.id where u.team_id = 882;\nselect * from nudges n where n.activity_search_id\n\nselect * from teams where created_at > '2026-03-09';\nSELECT * FROM crm_layouts WHERE crm_configuration_id = 1065; # 1065\nSELECT * FROM crm_layout_entities WHERE crm_layout_id = 3617;\n\nselect * from users where team_id = 1 and name like '%Lukas%'; # 7160\n\nSELECT * FROM teams WHERE id = 575;\nselect * from opportunities where team_id = 575;\nSELECT * FROM teams WHERE name LIKE '%Integrum ESG%'; # 1126, 1065,\nselect * from opportunities where team_id = 1126;\nSELECT * FROM teams WHERE name LIKE '%Base%'; # 1125, 1063,\nselect * from opportunities where team_id = 1125;\nselect * from contacts c\nwhere c.team_id = 882;\n\nSELECT * FROM activities WHERE id = 76822967;\nSELECT * FROM crm_profiles WHERE user_id = 15440;\nSELECT * FROM crm_profiles WHERE crm_configuration_id = 555;\nSELECT * FROM crm_configurations WHERE id = 555;\nSELECT * FROM users WHERE id = 15440; # team. 581, gr. 15440, pl. 3911, act. field 162182\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 581 and sa.provider = 'salesforce';\n\nSELECT * FROM automated_report_results order by id desc;\n\nselect * from features;\nselect * from team_features where feature_id = 40;\n\nselect * from teams where id = 556;\n\nselect * from automated_reports;\nwhere id = 54; # 4fdd41f6-dcf0-30d0-b339-7345381b6044 , [\"pdf\",\"podcast\"]\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\nselect * from automated_report_results order by id desc;\nSELECT * FROM automated_report_results WHERE id = 1919;\n\nselect * from automated_report_results WHERE report_id = 54;\n\nselect * from opportunities where id = 7594349;\n\nSELECT * FROM teams WHERE name LIKE '%Les%'; # 711, 692, 16067 - jiminnyintegration@lesmills.com\nselect * from playbooks where team_id = 711; # event 226147\nSELECT * FROM playbook_categories WHERE playbook_id = 5515;\nSELECT * FROM crm_fields WHERE crm_configuration_id = 692 and object_type = 'event';\nSELECT * FROM crm_fields WHERE id = 226147;\nSELECT * FROM crm_field_values WHERE crm_field_id = 226147;\n\nSELECT * FROM crm_configurations WHERE id = 692;\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 711 and sa.provider = 'salesforce';\n\nSELECT * FROM crm_profiles cp JOIN users u on u.id = cp.user_id WHERE u.team_id = 711;\n\nselect * from leads;\n\nselect * from calendars;\n\nSELECT\n t.id AS team_id,\n t.name,\n LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1)) AS calendar_domain\nFROM teams t\nJOIN users u ON u.team_id = t.id\nJOIN calendars c ON c.user_id = u.id AND c.status = 'active' AND c.calendar_provider_id LIKE '%@%'\nLEFT JOIN team_domains td\n ON td.team_id = t.id\n AND td.deleted_at IS NULL\n AND td.domain = LOWER(SUBSTRING_INDEX(c.calendar_provider_id, '@', -1))\nGROUP BY t.id, t.name, calendar_domain\nORDER BY t.name, calendar_domain;\n\nselect * from users u join calendars c on c.user_id = u.id\nwhere u.team_id = 882;\n\n\nselect * from activities where id = 74049485; # team 563 crm 537\nselect * from activities where id = 73272382; # team 563 crm 537\nselect * from activities where id = 64400389; # team 563 crm 537\nselect * from activities where id = 58081273; # team 563 crm 537\nselect * from activities where id = 54520297; # team 563 crm 537\nselect * from participants where activity_id = 58081273;\n\nselect * from activities where crm_configuration_id = 537 and provider = 'aircall'\nand account_id = 19003658 order by updated_at desc;\n\nselect * from contacts where crm_configuration_id = 537 and id = 35957759;\nselect * from accounts where crm_configuration_id = 537 and id = 19003658;\n\nselect * from automated_report_results where id = 1976;\nselect * from automated_reports where id = 583;\nselect * from activity_searches where id = 87714;\nselect * from activity_search_filters where activity_search_id = 87714;\n\nSELECT * FROM activities WHERE uuid_to_bin('8827f672-202d-4162-9d04-73ff5f0566a9') = uuid\nor uuid_to_bin('47842446-af51-4bcb-854f-cc6560290101') = uuid;\n\nSELECT * FROM crm_configurations WHERE provider = 'hubspot';\nselect * from rate_limits;\n\nselect * from automated_report_results where media_type = 'pdf' and status = 2\nand id IN (18, 1872);\nselect * from automated_reports where id = 54;\nSELECT * FROM users WHERE id IN (24623,29443,29613);\n\nSELECT * FROM automated_report_results WHERE uuid_to_bin('822fa41b-afd3-43a9-a248-86b0e36f3131') = uuid;\n\nselect * from text_relays where created_at > '2026-05-01';\nand id IN (32415, 32416);\n# and id = 32412;\n\nselect * from users where team_id = 2 and email like '%scott%' and id = 29510;\n\nSELECT * FROM activities WHERE uuid_to_bin('67cebfc2-ed56-44a2-8c68-7a0286ed8618') = uuid; # 79763436\n\nSELECT email_provider_id, COUNT(*) as count, GROUP_CONCAT(id) as ids, GROUP_CONCAT(status) as statuses\nFROM text_relays\nWHERE email_provider_id IN ('19e2027868a64b42', '19e2033ed8ea6b10')\nGROUP BY email_provider_id;\nSELECT id, status, telephony_provider_id, created_at\nFROM activities\nWHERE id IN (80028719, 80028846);\nSELECT id, status, code, email_sent_at, created_at, updated_at\nFROM text_relays\nWHERE id IN (32415, 32416);\nSELECT id, status, code, sender, recipient, created_at\nFROM text_relays\nWHERE sender LIKE '%mario.georgiev%' OR sender LIKE '%stoyan.tomov%'\nORDER BY created_at DESC\nLIMIT 10;\n\nSELECT id, uuid, status, code, sender, recipient, created_at, updated_at\nFROM text_relays\nWHERE uuid = uuid_to_bin('0626141c-27a6-4d8c-aff8-7c8020a2c656');\n\n# ***************\nSELECT DISTINCT u.id, u.email, u.name, u.softphone_number, COUNT(a.id) as sms_count\nFROM users u\nINNER JOIN activities a ON u.id = .user_id\nWHERE a.type LIKE 'sms%'\nAND a.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)\nGROUP BY u.id, u.email, u.name, u.softphone_number\nORDER BY sms_count DESC;\n\nselect * from teams where id = 1;\n\nselect * from roles;\n\n\nSELECT\n CONCAT(u.id, CASE WHEN u.id = t.owner_id THEN ' (owner)' ELSE '' END) AS user_id,\n u.email,\n sa.*,\n t.owner_id FROM social_accounts sa\nJOIN users u on u.id = sa.sociable_id\nJOIN teams t on t.id = u.team_id\nWHERE u.team_id = 1117 and sa.provider = 'hubspot';\nSELECT * FROM activities WHERE uuid_to_bin('8024fffb-2df7-4017-91f4-d9f896050248') = uuid; # 79933459 YES\nSELECT * FROM activities WHERE uuid_to_bin('25529043-8094-4781-927f-4f4da2a8185c') = uuid; # 80186192 NO\nSELECT * FROM crm_configurations WHERE id = 1053;\nSELECT * FROM teams WHERE id = 1117;\nselect * from users where id = 30249;\nselect * from playbooks where id = 5473;\nselect * from playbook_categories where id = 43783;\nselect * from playbook_categories where playbook_id = 5473;\nselect * from crm_fields where id = 659242;\nselect * from crm_field_values where crm_field_id = 659242;\n\nSELECT * FROM crm_field_data fd\n# JOIN crm_fields f ON fd.crm_field_id = f.id\n# JOIN activities a ON fd.activity_id = a.id\nWHERE activity_id = 79933459\n# AND f.crm_provider_id = 'hs_activity_type';\n\n\nSELECT * FROM activity_messages;\nselect * from text_relays where created_at > '2026-05-01';\nselect * from activities where user_id IN (7160, 18608) and created_at > '2026-05-22' order by id desc;\nselect * from users where team_id = 1 and id IN (18608, 13934, 7160);\nselect * from activities where user_id = 7160 order by id desc limit 10;\n\nselect * from accounts where team_id = 1 and name = 'Column5';\n\nselect * from users where name like '%Subra%'; # 31054, 1117\nselect * from teams where id = 1117;\nselect * from activity_searches where user_id = 31054;\nselect * from activity_search_filters where activity_search_id IN (88882, 88902);","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1556586073691613239
|
-7851921920897086523
|
typing_pause
|
accessibility
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86110
|
2954
|
20
|
2026-05-28T13:45:27.966463+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975927966_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-824022620702545522
|
-9103533727753729596
|
click
|
hybrid
|
NULL
|
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
JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerscreenpipereplaydWindowServercef_server Helper (Renderer)FirefoxCP Isolated Web ContentFirefoxlaunchservicesdFirefoxCP Isolated Web ContentcoreaudiodSlackiTerm2Slack HelperActivity MonitorSlack Helper (Renderer)tccdlanguage_server_macos_armcef_server Helper (GPU)Wispr Flow Helper (Renderer)FirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentWispr FlowFirefox GPU Helpersyspolicyd207,2149,087,677,648,736,614,210,19,89,28,76,25,85,04,84,24,03,23,23,13,13,02,92,82,82,82,52,5CPU Time22:06:58,072:05:23,281:29:37,203:28:01,205:30:43,317:41:03,476:26,6431:34,751:45:27,6659:01,034:28,8655:48,7712:51,981:02:16,7013:20,825:46,5756:09,918:24,025:36,075:54,443:04,3416:37,1839:57,8422:10,6114:38,692:07,942:23:15,7716:21,54System:User:Idle:Threads % <7Thu 28 May 16:45:27CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind % GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple48,18%46,89%4,93%0,06,80,00,00,045,00,00,00,00,00,00,00,00,44,00,00,00,00,00,60,00,00,00,00,00,03,10,0CPU LOADThreads:Processes:GPU Time0,005:54,050,005:03,900,003:14:58,840,000,000,040,000,000,000,0517,545:46,730,000,000,000,001:42,520,000,000,000,000,000,0023:50,950,00PID lahlUserrootlukaslukaslukaslukas_windowserverlukaslukaslukasrootlukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootSearch...
|
86108
|
NULL
|
NULL
|
NULL
|
|
86109
|
2955
|
15
|
2026-05-28T13:45:27.863031+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975927863_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6423983795013307969
|
-3852565242246887264
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
#12121 on JY-20963-fix-im Project: faVsco.js, menu
#12121 on JY-20963-fix-import-on-deleted-entity, menu
rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectsTrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.onpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiServiceTestDeleteObjectsTrait.php© Client.php• Thu 28 May 16:45:27+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwichAetew045 A1 A41 У 66 AГОШЕОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE uuid_ to_bin ("[CREDIT_CARD]-927f-4f4da288185e') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick off a new oro© Fixcing and Validating TextRelayService TestsExplain to me how does this branch dift solves the issue with. ls this acurata description: When an opportunity isormupis ore-lnktchedscurs in Salesforco:importOpportunity (and similar methods for Lead, Account, Contact):sforce returns a record with IsDeleted = true, the code:neoendsdieobjectdeletion (because IsDeleted = trueмуо ооу e oatin bacd on Aeyvny auoan be etene ca oa un ty no longe oitain theAdhotvXtModtur Tatme Toxcsehireht4 space...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86108
|
2954
|
19
|
2026-05-28T13:45:25.927904+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975925927_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1556586073691613239
|
-7851921920897086523
|
typing_pause
|
accessibility
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86107
|
2955
|
14
|
2026-05-28T13:45:25.786088+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975925786_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"bounds":{"left":0.35272607,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"bounds":{"left":0.3643617,"top":0.12529927,"width":0.012632979,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.37898937,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"bounds":{"left":0.38896278,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.40026596,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.40757978,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":4,"bounds":{"left":0.13863032,"top":0.070231445,"width":0.38597074,"height":0.92976856},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"bounds":{"left":0.41589096,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"bounds":{"left":0.4245346,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"bounds":{"left":0.43550533,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"bounds":{"left":0.44414893,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"bounds":{"left":0.45279256,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"bounds":{"left":0.4637633,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"bounds":{"left":0.47473404,"top":0.09896249,"width":0.024268618,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"bounds":{"left":0.5013298,"top":0.09896249,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1556586073691613239
|
-7851921920897086523
|
typing_pause
|
accessibility
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
86105
|
NULL
|
NULL
|
NULL
|
|
86106
|
2954
|
18
|
2026-05-28T13:45:23.777066+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975923777_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Activity MonitorFileEditWindowActivity MonitorAll Activity MonitorFileEditWindowActivity MonitorAll ProcessesProcess Namekernel_taskPhpStormVirtual Machine Service for DockerreplaydWindowServerscreenpipeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxiTerm2coreaudiodSlack HelperActivity MonitorSlack Helper (Renderer)cef_server Helper (Renderer)language_server_macos_armFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentViewHelp% CPUWispr Flow Helper (Renderer)CursorUlViewService (Not Responding)cef_server Helper (GPU)ClaudeFirefoxCP Isolated Web ContentFirefox GPU HelperNotion Helper (Renderer)FirefoxCP Isolated Web Content180,8139,1109,247,240,439,37,97,47,05,55,04,84,54,13,93,73,63,33,23,22,92,82,72,52,42,32,22,2CPU Time22:06:47,112:05:15,401:29:32,575:30:40,737:41:01,533:27:57,104:28,4031:34,221:45:27,141:02:16,4455:48,4413:20,565:46,3556:09,706:25,895:35,9016:37,0214:38,5422:10,463:15,603:04,185:41,995:54,2827:51,6939:57,692:23:15,6423:33,1423:32,43System:User:Idle:Threads (ah)100% CThu 28 May 16:45:23CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,06,80,00,045,70,00,00,00,00,40,03,80,00,00,00,00,00,00,00,00,00,00,50,00,03,70,00,051,57%47,73%0,70%CPU LOADThreads:Processes:SearchGPU Time0,005:53,890,000,003:14:58,065:03.900,000,000,0417,530,005:46,650,000,000,000,000,000,000,000,000,000,001:42,500,000,0023:50,950,000,00PID Userrootlukaslukaslukas_windowserverlukaslukaslukaslukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukas...
|
NULL
|
9147416948149895716
|
NULL
|
typing_pause
|
ocr
|
NULL
|
Activity MonitorFileEditWindowActivity MonitorAll Activity MonitorFileEditWindowActivity MonitorAll ProcessesProcess Namekernel_taskPhpStormVirtual Machine Service for DockerreplaydWindowServerscreenpipeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxiTerm2coreaudiodSlack HelperActivity MonitorSlack Helper (Renderer)cef_server Helper (Renderer)language_server_macos_armFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentViewHelp% CPUWispr Flow Helper (Renderer)CursorUlViewService (Not Responding)cef_server Helper (GPU)ClaudeFirefoxCP Isolated Web ContentFirefox GPU HelperNotion Helper (Renderer)FirefoxCP Isolated Web Content180,8139,1109,247,240,439,37,97,47,05,55,04,84,54,13,93,73,63,33,23,22,92,82,72,52,42,32,22,2CPU Time22:06:47,112:05:15,401:29:32,575:30:40,737:41:01,533:27:57,104:28,4031:34,221:45:27,141:02:16,4455:48,4413:20,565:46,3556:09,706:25,895:35,9016:37,0214:38,5422:10,463:15,603:04,185:41,995:54,2827:51,6939:57,692:23:15,6423:33,1423:32,43System:User:Idle:Threads (ah)100% CThu 28 May 16:45:23CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,06,80,00,045,70,00,00,00,00,40,03,80,00,00,00,00,00,00,00,00,00,00,50,00,03,70,00,051,57%47,73%0,70%CPU LOADThreads:Processes:SearchGPU Time0,005:53,890,000,003:14:58,065:03.900,000,000,0417,530,005:46,650,000,000,000,000,000,000,000,000,000,001:42,500,000,0023:50,950,000,00PID Userrootlukaslukaslukas_windowserverlukaslukaslukaslukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukas...
|
86104
|
NULL
|
NULL
|
NULL
|
|
86105
|
2955
|
13
|
2026-05-28T13:45:21.133663+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975921133_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-209 rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerce traitsCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyinterclprowdwewe ehr nong PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiServiceTestDeleteObjectsTrait.php© Client.php• Thu 28 May 16:45:21+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaumeeendhlesOrcherwichAetew045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2926-05-91*-* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick off a new oro© Fixcing and Validating TextRelayService TestsExplain to me how does this branch dift solves the issue with. ls the description: When an opportunity is deletedton joo runs, scuvides unhe delated omno tiaity euiaino a forkian teu consteiat vialutionscurs in Salesforco:importOpportunity (and similar methods for Lead, Account, Contact):sforce returns a record with isDeleted = true, the code:neoendsdieobjectdeletion (because IsDeleted = true• муо оон e outin bocd on Aey o uoen be etete o oa tu t ne longr oitein theAdhotvXtModtur Tatme Toxcsehireh*4 space...
|
NULL
|
6109665602647891266
|
NULL
|
click
|
ocr
|
NULL
|
rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-209 rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerce traitsCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceonwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.ohpyTeom.oneuser.3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyinterclprowdwewe ehr nong PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awen snt1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiServiceTestDeleteObjectsTrait.php© Client.php• Thu 28 May 16:45:21+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.php# console fiaumeeendhlesOrcherwichAetew045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2926-05-91*-* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick off a new oro© Fixcing and Validating TextRelayService TestsExplain to me how does this branch dift solves the issue with. ls the description: When an opportunity is deletedton joo runs, scuvides unhe delated omno tiaity euiaino a forkian teu consteiat vialutionscurs in Salesforco:importOpportunity (and similar methods for Lead, Account, Contact):sforce returns a record with isDeleted = true, the code:neoendsdieobjectdeletion (because IsDeleted = true• муо оон e outin bocd on Aey o uoen be etete o oa tu t ne longr oitein theAdhotvXtModtur Tatme Toxcsehireh*4 space...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86104
|
2954
|
17
|
2026-05-28T13:45:21.032231+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975921032_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Activity MonitorFileEditWindowActivity MonitorAll Activity MonitorFileEditWindowActivity MonitorAll ProcessesProcess Namekernel_taskPhpStormVirtual Machine Service for DockerreplaydWindowServerscreenpipeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxiTerm2coreaudiodSlack HelperActivity MonitorSlack Helper (Renderer)cef_server Helper (Renderer)language_server_macos_armFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentViewHelp% CPUWispr Flow Helper (Renderer)CursorUlViewService (Not Responding)cef_server Helper (GPU)ClaudeFirefoxCP Isolated Web ContentFirefox GPU HelperNotion Helper (Renderer)FirefoxCP Isolated Web Content180,8139,1109,247,240,439,37,97,47,05,55,04,84,54,13,93,73,63,33,23,22,92,82,72,52,42,32,22,2CPU Time22:06:47,112:05:15,401:29:32,575:30:40,737:41:01,533:27:57,104:28,4031:34,221:45:27,141:02:16,4455:48,4413:20,565:46,3556:09,706:25,895:35,9016:37,0214:38,5422:10,463:15,603:04,185:41,995:54,2827:51,6939:57,692:23:15,6423:33,1423:32,43System:User:Idle:Threads alol100% CThu 28 May 16:45:20CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,06,80,00,045,70,00,00,00,00,40,03,80,00,00,00,00,00,00,00,00,00,00,50,00,03,70,00,040,90%48,79%10,31%CPU LOADThreads:Processes:SearchGPU Time0,005:53,890,000,003:14:58,065:03.900,000,000,0417,530,005:46,650,000,000,000,000,000,000,000,000,000,001:42,500,000,0023:50,950,000,00PID Userrootlukaslukaslukas_windowserverlukaslukaslukaslukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukas...
|
NULL
|
8484145636178131676
|
NULL
|
click
|
ocr
|
NULL
|
Activity MonitorFileEditWindowActivity MonitorAll Activity MonitorFileEditWindowActivity MonitorAll ProcessesProcess Namekernel_taskPhpStormVirtual Machine Service for DockerreplaydWindowServerscreenpipeFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxiTerm2coreaudiodSlack HelperActivity MonitorSlack Helper (Renderer)cef_server Helper (Renderer)language_server_macos_armFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentViewHelp% CPUWispr Flow Helper (Renderer)CursorUlViewService (Not Responding)cef_server Helper (GPU)ClaudeFirefoxCP Isolated Web ContentFirefox GPU HelperNotion Helper (Renderer)FirefoxCP Isolated Web Content180,8139,1109,247,240,439,37,97,47,05,55,04,84,54,13,93,73,63,33,23,22,92,82,72,52,42,32,22,2CPU Time22:06:47,112:05:15,401:29:32,575:30:40,737:41:01,533:27:57,104:28,4031:34,221:45:27,141:02:16,4455:48,4413:20,565:46,3556:09,706:25,895:35,9016:37,0214:38,5422:10,463:15,603:04,185:41,995:54,2827:51,6939:57,692:23:15,6423:33,1423:32,43System:User:Idle:Threads alol100% CThu 28 May 16:45:20CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind% GPU AppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,06,80,00,045,70,00,00,00,00,40,03,80,00,00,00,00,00,00,00,00,00,00,50,00,03,70,00,040,90%48,79%10,31%CPU LOADThreads:Processes:SearchGPU Time0,005:53,890,000,003:14:58,065:03.900,000,000,0417,530,005:46,650,000,000,000,000,000,000,000,000,000,001:42,500,000,0023:50,950,000,00PID Userrootlukaslukaslukas_windowserverlukaslukaslukaslukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukaslukas...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86103
|
2955
|
12
|
2026-05-28T13:45:17.437308+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975917437_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"bounds":{"left":0.35272607,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"bounds":{"left":0.3643617,"top":0.12529927,"width":0.012632979,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"bounds":{"left":0.37898937,"top":0.12529927,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"bounds":{"left":0.38896278,"top":0.12529927,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.40026596,"top":0.123703115,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.40757978,"top":0.123703115,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":4,"bounds":{"left":0.13863032,"top":0.070231445,"width":0.38597074,"height":0.92976856},"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1556586073691613239
|
-7851921920897086523
|
typing_pause
|
accessibility
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
86101
|
NULL
|
NULL
|
NULL
|
|
86102
|
2954
|
16
|
2026-05-28T13:45:17.332617+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975917332_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1556586073691613239
|
-7851921920897086523
|
typing_pause
|
accessibility
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
86100
|
NULL
|
NULL
|
NULL
|
|
86101
|
2955
|
11
|
2026-05-28T13:45:14.795916+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975914795_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-209 rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.onpyTeom.oneuseh3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.php•• Thu 28 May 16:45:14ServiceTest+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwich045 A1 A41 У 66 AГОШЕОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick of a new proisct. Make chunoeca Salesforca Servico Test Coverao.C Fixing and Validating TextRelayService Testsc, Hubspol Poling Lock AnalystCnletm kur don ihie tranah Alohoee hlhmiewhe th deee nnn" PodnAdhotvXtModtur Tatme Toxcsehireh*4 space...
|
NULL
|
-1178045157803802401
|
NULL
|
click
|
ocr
|
NULL
|
rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-209 rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.onpyTeom.oneuseh3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.php•• Thu 28 May 16:45:14ServiceTest+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwich045 A1 A41 У 66 AГОШЕОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick of a new proisct. Make chunoeca Salesforca Servico Test Coverao.C Fixing and Validating TextRelayService Testsc, Hubspol Poling Lock AnalystCnletm kur don ihie tranah Alohoee hlhmiewhe th deee nnn" PodnAdhotvXtModtur Tatme Toxcsehireh*4 space...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86100
|
2954
|
15
|
2026-05-28T13:45:14.694167+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975914694_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
5582423643801155883
|
-8994321436789994556
|
click
|
hybrid
|
NULL
|
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
Activity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormnsattributedstringagentVirtual Machine Service for DockerscreenpipereplaydWindowServerFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentcoreaudiodFirefoxiTerm2CursorUlViewService (Not Responding)Slack HelperActivity MonitorSlack Helper (Renderer)FirefoxCP Isolated Web Contentcef_server Helper (Renderer)ClaudeWispr Flownsattributedstringagent Web ContentWispr Flow Helper (Renderer)launchservicesdFirefox GPU Helpercef_server Helper (GPU)Notion Calendar Helper (Renderer)FirefoxCP Isolated Web Contentlanguage_server_macos_arm203,3131,267,463,756,747,743,310,29,27,57,25,05,04,94,94,24,04,03,33,23,23,13,02,92,82,62,42,4CPU Time22:06:27,962:05:00,954,361:29:21,663:27:51,235:30:34,377:40:57,724:27,4931:33,3755:47,911:45:26,261:02:15,885:41,8413:20,055:45,8756:09,2729:01,466:25,5327:51,462:07,550,473:03,8758:59,812:23:15,385:54,009:30,4414:38,245:35,53System:User:Idle:Threads alol100% CThu 28 May 16:45:14CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind % GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple42,78%48,91%8,31%0,06,40,00,00,00,052,70,00,00,00,00,40,04,10,00,00,00,00,00,00,00,00,05,90,30,00,00,0CPU LOADThreads:Processes:SearchGPU Time0,005:53,450,000,005:03,900,003:14:56,390,000,000,000,0417,510,005:46,500,000,000,000,000,000,000,000,000,0023:50,931:42,430,000,000,00PID Userrootlukaslukaslukaslukaslukas_windowserverlukaslukas_coreaudiod...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86099
|
2955
|
10
|
2026-05-28T13:45:12.647803+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975912647_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6806313800532383212
|
-8129894190043395130
|
typing_pause
|
hybrid
|
NULL
|
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
Sync Changes
Hide This Notification
rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.onpyTeom.oneuseh3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.php•• Thu 28 May 16:45:12ServiceTest+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwich045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick of a new proisct. Make chunoeca Salesforca Servico Test Coverao.C Fixing and Validating TextRelayService Testsc, Hubspol Poling Lock AnalystCnletm kur don ihie tranah Alohoee hlhmiewhe th deee nnn" PodnAdhotvXtModtur Tatme Toxcsehirehensd....
|
86097
|
NULL
|
NULL
|
NULL
|
|
86098
|
2954
|
14
|
2026-05-28T13:45:11.182688+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975911182_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
8569994499127135030
|
-3986301007428073024
|
typing_pause
|
hybrid
|
NULL
|
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
Activity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormnsattributedstringagentVirtual Machine Service for DockerscreenpipereplaydWindowServerFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentcoreaudiodFirefoxiTerm2CursorUlViewService (Not Responding)Slack HelperActivity MonitorSlack Helper (Renderer)FirefoxCP Isolated Web Contentcef_server Helper (Renderer)ClaudeWispr Flownsattributedstringagent Web ContentWispr Flow Helper (Renderer)launchservicesdFirefox GPU Helpercef_server Helper (GPU)Notion Calendar Helper (Renderer)FirefoxCP Isolated Web Contentlanguage_server_macos_arm203,3131,267,463,756,747,743,310,29,27,57,25,05,04,94,94,24,04,03,33,23,23,13,02,92,82,62,42,4CPU Time22:06:27,962:05:00,954,361:29:21,663:27:51,235:30:34,377:40:57,724:27,4931:33,3755:47,911:45:26,261:02:15,885:41,8413:20,055:45,8756:09,2729:01,466:25,5327:51,462:07,550,473:03,8758:59,812:23:15,385:54,009:30,4414:38,245:35,53System:User:Idle:Threads alol100% CThu 28 May 16:45:10CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind % GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple47,24%52,53%0,23%0,06,40,00,00,00,052,70,00,00,00,00,40,04,10,00,00,00,00,00,00,00,00,05,90,30,00,00,0CPU LOADThreads:Processes:SearchGPU Time0,005:53,450,000,005:03,900,003:14:56,390,000,000,000,0417,510,005:46,500,000,000,000,000,000,000,000,000,0023:50,931:42,430,000,000,00PID Userrootlukaslukaslukaslukaslukas_windowserverlukaslukas_coreaudiod...
|
86096
|
NULL
|
NULL
|
NULL
|
|
86097
|
2955
|
9
|
2026-05-28T13:45:09.087880+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975909087_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
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'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.11569149,"height":0.025538707},"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.85638297,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"bounds":{"left":0.87167555,"top":0.019952115,"width":0.043882977,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-5925516468550469094
|
-8643040614315223040
|
typing_pause
|
hybrid
|
NULL
|
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'
rapstomWewCoocWindowFV faVsco.|s ~#12121 on JY-20963-fx-lProinet vWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.onwchcimseneorconoeCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUeerRokObserver.onpyTeom.oneuseh3 Бclass Service extends BaseServce inolements.Salesfonceintenface.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfaceonorrchus nesspracessesiintentaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.CaerchitiknrcrrdceCayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt isei Wiii nhey*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.php•• Thu 28 May 16:45:09ServiceTest+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwich045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 onder bu ja desc 1ini+ 18.*fnon accounts where tean 3a = 1and nane = "Colunos*.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick of a new proisct. Make chunoeca Salesforca Servico Test Coverao.C Fixing and Validating TextRelayService Testsc, Hubspol Poling Lock AnalystCeletm kur don ihie Wranah Al oboee thalhmiewoheth" PodnAdhotvXtModtur Tatme Toxcsehirehensd....
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86096
|
2954
|
13
|
2026-05-28T13:45:07.348260+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975907348_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerWindowServerreplaydFirefoxCP Isolated Web Contentmds_storesFirefoxscreenpipeFirefox GPU HelperFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentlaunchservicesdiTerm2FirefoxCP Isolated Web ContentcoreaudiodSlack HelperNotion Calendar Helper (Renderer)Activity MonitorSlack Helper (Renderer)language_server_macos_armPerfPowerServicesWispr Flow Helper (Renderer)SlackFirefoxCP Isolated Web ContentWispr FlowlaunchdFirefoxCP Isolated Web Content195,884,571,257,255,332,628,118,314,710,49,78,48,15,45,25,14,54,34,03,63,43,33,12,92,72,52,32,1CPU Time22:06:17,082:04:53,931:29:18,257:40:55,405:30:31,824:26,951:02:35,031:45:25,883:27:48,192:23:15,2329:01,2531:32,8858:59,651:02:15,6216:36,5255:47,5113:19,799:30,305:45,6156:09,045:35,411:40,803:03,7012:51,3739:57,372:07,3818:13,9710:57,75System:User:Idle:Threads lahl100% CThu 28 May 16:45:06CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind % GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,05,40,047,10,00,00,00,00,06,10,00,00,00,40,00,03,70,00,00,00,00,00,00,00,00,00,00,051,13%48,05%0,82%CPU LOADThreads:Processes:SearchGPU Time0,005:53,300,003:14:55,440,000,000,000,045:03,9023:50,900,000,000,0017,510,000,005:46,420,000,000,000,000,000,000,050,000,000,000,00PID Userrootlukaslukas_windowserverlukaslukasrootlukaslukaslukaslukaslukasrootlukaslukas_coreaudiodlukaslukaslukaslukaslukasrootlukaslukaslukaslukasrootlukas...
|
NULL
|
1919393326222690823
|
NULL
|
visual_change
|
ocr
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormVirtual Machine Service for DockerWindowServerreplaydFirefoxCP Isolated Web Contentmds_storesFirefoxscreenpipeFirefox GPU HelperFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentlaunchservicesdiTerm2FirefoxCP Isolated Web ContentcoreaudiodSlack HelperNotion Calendar Helper (Renderer)Activity MonitorSlack Helper (Renderer)language_server_macos_armPerfPowerServicesWispr Flow Helper (Renderer)SlackFirefoxCP Isolated Web ContentWispr FlowlaunchdFirefoxCP Isolated Web Content195,884,571,257,255,332,628,118,314,710,49,78,48,15,45,25,14,54,34,03,63,43,33,12,92,72,52,32,1CPU Time22:06:17,082:04:53,931:29:18,257:40:55,405:30:31,824:26,951:02:35,031:45:25,883:27:48,192:23:15,2329:01,2531:32,8858:59,651:02:15,6216:36,5255:47,5113:19,799:30,305:45,6156:09,045:35,411:40,803:03,7012:51,3739:57,372:07,3818:13,9710:57,75System:User:Idle:Threads lahl100% CThu 28 May 16:45:06CPUMemoryEnergyDiskNetworkIdle Wake-UpsKind % GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,05,40,047,10,00,00,00,00,06,10,00,00,00,40,00,03,70,00,00,00,00,00,00,00,00,00,00,051,13%48,05%0,82%CPU LOADThreads:Processes:SearchGPU Time0,005:53,300,003:14:55,440,000,000,000,045:03,9023:50,900,000,000,0017,510,000,005:46,420,000,000,000,000,000,000,050,000,000,000,00PID Userrootlukaslukas_windowserverlukaslukasrootlukaslukaslukaslukaslukasrootlukaslukas_coreaudiodlukaslukaslukaslukaslukasrootlukaslukaslukaslukasrootlukas...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86095
|
2955
|
8
|
2026-05-28T13:45:04.728586+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975904728_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
rapstomvewCoocWindowFV faVsco.is ~#12121 on JY-209 rapstomvewCoocWindowFV faVsco.is ~#12121 on JY-20963-folproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFadse intonsrno© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceon© CachedCrmServiceDecoratoreCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo# ProsnectSearchScope.phpUsciko cooscrytrionyTeom.oneusehclass Service extends BaseService nol esents.Salesfonceintenface.SalesforceBatchsyncinterface,Suncimenta tles intentace.$ %SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensacecprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.RemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.Caarchtaskintertace,CayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.PasotalotaEnt/twManfoul.t5onTotonfaceVenyusxexsenrentocUst Keso Veconoanynaneoyrhartiruse Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu bim tor the old Note-takiina Tool*awennh1 usagoanivate conet nt hsei Wins teNeiheyih*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.phpTO0У L7Inu cowoy 10.40.0"+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwich045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2926-05-91*-* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antéusty seanches whene usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cachado Codo XoKick of a new aroisct. Make chunoeca Salesforca Servico Test Coverao.C Fixing and Validating TextRelayService Testsc, Hubspol Poling Lock AnalystCnlamkhur dor ihie Kranak lohoe hlhiwt" PodnAdhotvNtwodtur Tasms Tox-se Whies...
|
NULL
|
7721605764817278951
|
NULL
|
visual_change
|
ocr
|
NULL
|
rapstomvewCoocWindowFV faVsco.is ~#12121 on JY-209 rapstomvewCoocWindowFV faVsco.is ~#12121 on JY-20963-folproidetWnceredonAe>u Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFadse intonsrno© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoeoreseviceon© CachedCrmServiceDecoratoreCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohC@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo# ProsnectSearchScope.phpUsciko cooscrytrionyTeom.oneusehclass Service extends BaseService nol esents.Salesfonceintenface.SalesforceBatchsyncinterface,Suncimenta tles intentace.$ %SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensacecprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.RemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.Caarchtaskintertace,CayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.PasotalotaEnt/twManfoul.t5onTotonfaceVenyusxexsenrentocUst Keso Veconoanynaneoyrhartiruse Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu bim tor the old Note-takiina Tool*awennh1 usagoanivate conet nt hsei Wins teNeiheyih*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.phpTO0У L7Inu cowoy 10.40.0"+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwich045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity-messages:* fron text. relayswhere created at > *2926-05-91*-* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desciteam.id = 1 and 3d TN (18688, 13934, 2169):*£ron activities where usen 1a = 7169 order bu jd desc 1ini+ 10:*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antéusty seanches whene usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cachado Codo XoKick of a new aroisct. Make chunoeca Salesforca Servico Test Coverao.C Fixing and Validating TextRelayService Testsc, Hubspol Poling Lock AnalystCnlamkhur dor ihie Kranak lohoe hlhiwt" PodnAdhotvNtwodtur Tasms Tox-se Whies...
|
86093
|
NULL
|
NULL
|
NULL
|
|
86094
|
2954
|
12
|
2026-05-28T13:45:04.179488+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975904179_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormWindowServerVirtual Machine Service for DockerscreenpipeFirefoxCP Isolated Web ContentreplaydFirefoxmds_storesFirefox GPU HelperFirefoxCP Isolated Web ContentSlack HelpelSlack Heiper (Renderer)Control CentreWispr Flow Helper (Renderer)language_server_macos_armcorebrightnessdFirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web ContentPerfPowerServiceslogdFirefoxCP Isolated Web ContentPSPhpStorm211,3122,160,659,149,038,437,731,817,312,69,65,14,13,43,12,92,32,32,12,01,91,71,7CPU Time22:06:06,642:04:49,427:40:52,351:29:14,453:27:47,414:25,215:30:28,871:45:24,901:02:33,532:23:14,6731:32,43802:15,33$113:19,5556:08,8519:26,583:03,545:35,222:26,0414:38,012:07,2416:36,241:40,6314:57,2229:00,73System:User:Idle:Threads CPUMemoryIdle Wake-Ups .82%44,77%1,41%EnergyDiskNetworkKind% GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,04,741,40,00,00,00,00,00,05,10,00,30,00,00,00,00,00,00,00,00,00,00,0CPU LOADThreads:Processes:GPU Time0,005:53,293:14:54,490,005:03,900,000,000,040,0023:50,720,000,000,040,000,000,000,000,000,000,000,000,00‹ >0 (hlPID N Userrootlukaswindowserverlukaslukaslukaslukaslukasrootlukaslukaslukaslukaslukaslukasrootlukaslukaslukasrootrootlukas100% C8• Thu 28 May 16:45:03Search...
|
NULL
|
2213492328931145150
|
NULL
|
visual_change
|
ocr
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_taskPhpStormWindowServerVirtual Machine Service for DockerscreenpipeFirefoxCP Isolated Web ContentreplaydFirefoxmds_storesFirefox GPU HelperFirefoxCP Isolated Web ContentSlack HelpelSlack Heiper (Renderer)Control CentreWispr Flow Helper (Renderer)language_server_macos_armcorebrightnessdFirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web ContentPerfPowerServiceslogdFirefoxCP Isolated Web ContentPSPhpStorm211,3122,160,659,149,038,437,731,817,312,69,65,14,13,43,12,92,32,32,12,01,91,71,7CPU Time22:06:06,642:04:49,427:40:52,351:29:14,453:27:47,414:25,215:30:28,871:45:24,901:02:33,532:23:14,6731:32,43802:15,33$113:19,5556:08,8519:26,583:03,545:35,222:26,0414:38,012:07,2416:36,241:40,6314:57,2229:00,73System:User:Idle:Threads CPUMemoryIdle Wake-Ups .82%44,77%1,41%EnergyDiskNetworkKind% GPUAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleApple0,04,741,40,00,00,00,00,00,05,10,00,30,00,00,00,00,00,00,00,00,00,00,0CPU LOADThreads:Processes:GPU Time0,005:53,293:14:54,490,005:03,900,000,000,040,0023:50,720,000,000,040,000,000,000,000,000,000,000,000,00‹ >0 (hlPID N Userrootlukaswindowserverlukaslukaslukaslukaslukasrootlukaslukaslukaslukaslukaslukasrootlukaslukaslukasrootrootlukas100% C8• Thu 28 May 16:45:03Search...
|
86092
|
NULL
|
NULL
|
NULL
|
|
86082
|
2954
|
6
|
2026-05-28T13:44:32.258828+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975872258_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_task189,8Virtual Machine Service for Docker86,8PhpStorm75,6WindowServer70,2replaydFirefoxCP Isolated Web ContentFirefox GPU HelperscreenpipeFirefoxFirefoxCP Isolated Web Contentlanguage_server_macos_armFirefoxCP Isolated Web ContentiTerm2FirefoxCP Isolated Web ContentlaunchservicesdFirefoxCP Isolated Web ContentActivity MonitorcoreaudiodSlack HelperSlack Helper (Renderer)FirefoxCP Isolated Web Contentcef_server Helper (Renderer)Control CentreWispr Flow Helper (Renderer)FirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentCPU Time22:04:59,651:28:56,712:04:05,677:40:34,875:30:12,742:02,212:23:10,283:27:33,611:45:19,1716:35,545:32,8531:29,611:02:13,6128:59,1258:57,794:21,205:43,1855:45,5113:18,0356:07,5339:56,766:24,9019:25,733:02,4710:57,032:06,5422:09,7614:37,22System:User:Idle:Threads CPUMemoryIdle Wake-Ups EnergyKindAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleCPU LOADDisk51,23%48,77%0,00%Network% GPU0,00,04,253,60,00,09,50,00,00,00,00,00,30,00,00,00,00,03,70,00,00,00,00,00,00,00,00,0Threads:Processes:GPU Time‹ >0 (hlPID % CThu 28 May 16:44:31SearchUserrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootlukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukas...
|
NULL
|
404109252785519537
|
NULL
|
click
|
ocr
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_task189,8Virtual Machine Service for Docker86,8PhpStorm75,6WindowServer70,2replaydFirefoxCP Isolated Web ContentFirefox GPU HelperscreenpipeFirefoxFirefoxCP Isolated Web Contentlanguage_server_macos_armFirefoxCP Isolated Web ContentiTerm2FirefoxCP Isolated Web ContentlaunchservicesdFirefoxCP Isolated Web ContentActivity MonitorcoreaudiodSlack HelperSlack Helper (Renderer)FirefoxCP Isolated Web Contentcef_server Helper (Renderer)Control CentreWispr Flow Helper (Renderer)FirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentCPU Time22:04:59,651:28:56,712:04:05,677:40:34,875:30:12,742:02,212:23:10,283:27:33,611:45:19,1716:35,545:32,8531:29,611:02:13,6128:59,1258:57,794:21,205:43,1855:45,5113:18,0356:07,5339:56,766:24,9019:25,733:02,4710:57,032:06,5422:09,7614:37,22System:User:Idle:Threads CPUMemoryIdle Wake-Ups EnergyKindAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleCPU LOADDisk51,23%48,77%0,00%Network% GPU0,00,04,253,60,00,09,50,00,00,00,00,00,30,00,00,00,00,03,70,00,00,00,00,00,00,00,00,0Threads:Processes:GPU Time‹ >0 (hlPID % CThu 28 May 16:44:31SearchUserrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootlukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukas...
|
86080
|
NULL
|
NULL
|
NULL
|
|
86081
|
2955
|
1
|
2026-05-28T13:44:28.848321+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975868848_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
rnostolFV faVsco.is ~#12121 on JY-20963-fix-imProi rnostolFV faVsco.is ~#12121 on JY-20963-fix-imProinet vsuwice.phpWnceredonAe>( Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.on© CachedCrmServiceDecoratoreCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohMatchDomainsy Emalnterta.C@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUsciko cooscrytrionyTeom.oney kemetonsuser.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.class Service extends BaseService nol esents.Salesfonceintenface.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.SearchTaskIntenface,CayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt hsei Wins teNeiheyih*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.php100% L74o Inu co moy 10:44:20ServiceTest :Q+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwichAetew045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desci* fron users where toam.id = 1 and 3d TN 618608, 13934, 2169):*£ron activities where usen 1a = 7169 order bu ja desc 1ini+ 18:*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick of a new aroisct. Make chunoee salestorce servico vest coveragtFixing and Validating TextRelayService TesteCnlamkhur dor ihie Kranak lohoe hlhiwt" PodnAdhotv• oNtwodtur Tasms Tox-se Whies...
|
NULL
|
-1830363349847409320
|
NULL
|
click
|
ocr
|
NULL
|
rnostolFV faVsco.is ~#12121 on JY-20963-fix-imProi rnostolFV faVsco.is ~#12121 on JY-20963-fix-imProinet vsuwice.phpWnceredonAe>( Listeners>(D Metadata>E Miaraton>&a Pipedriveyusalestorch- Fields= OpporunitvMatchenopportunitySyncstrategy› ProspectsearchStrategySerice traiisCleentondCDecmrate-cmwonnDeleteObjectstrait.phpcFaldseintonscho© PayloadBuilder.php© Profile.php© QueryBuilder.php© QueryHandler.php© Querylterator.php© QueryResults.phpo service.onlp© SyncBatchRedisService.ptD Traits© BaseClient.phoebrseoemce.on© CachedCrmServiceDecoratoreCouniincootkiowond(C CrmActivitvProviderintearateCCmAciMweiMoonrmConticurationSettinasserC) Detault Prospec SearchStrate)FindsProspectintertace.ohMatchDomainsy Emalnterta.C@oportun tvact vitwMatcherOpportur tysvncstratecvintecprosnaeteache.ooo#ProspectSearchScope.phpUsciko cooscrytrionyTeom.oney kemetonsuser.cprosnae SaatchSrathoe© ProspectSearchStrategyintercprowdweeeeir dodg PococdSalnator nhoPacolvoCommaoNamAp.Em.class Service extends BaseService nol esents.Salesfonceintenface.SalesforceBatchsyncinterface,Suncimenta tles intentace.SynccrmProf1LeRecordtypesinterfacesonortcrus ness?racessesiintensaceRemoteEntityHanipulationinterfaceFerchte arednciavilyanterrace.SendSur arviocrhinterrace.Matchao ginsy-naztnterrace.SearchTaskIntenface,CayouchondgenencinterraceSettingsIntenface.MatchfoeFntftfacTntanfacsкeaoreche ylookue htertaceSupports0briectTypeParseInterface.RemoteNoteEntityManipulationInterfaceVenyusxexsenrentocUst Koso Veronoanynaneoychartrause Syncheldstraltuse velereubnecistraltuse Recordiantou atonstrart.use Servcetralts Barchsynchrartuse FollowupActivityTrait:use Looctviryiralt* Note Bodu Bioit for the 0ld Note-Taksina Tool*awennh1 usageanivate conet nt hsei Wins teNeiheyih*Note Contont Isest fon the Nor Nothe*Avan inttussge(C) TimaPorioditacatos nhaprivate const int ENHANCEO_NOTE MAX LENGTH = 50000000MimnarM Kinckprivate const steing INSTALLED PACKAGEП0 = *В33Т-60009876K67AT*• M MaiDeleteObjectsTrait.php© Client.php100% L74o Inu co moy 10:44:20ServiceTest :Q+0.A SF fiminny@localhost)A console [STAGING)D0000Ty: AutowINTERVAL 38 DAY.BY u.id, u.email, u.name, u.softphone numbenBY sns_count DESC;* fron teans where id = 1;console (PROD) x C Service.phpds consoe leu,eeendhlesOrcherwichAetew045 A1 A41 У 66 AГОШТОЕНВШІ714715-718719720BEERRBREBERRERTYNCAT(u.id, CASE WHEN u.id = t.ouner_id THEN • (ouner)* ELSE ** END) AS user_idouner_id FROM social_accounts saeans t 1.nc->1ontd n.teandU.tean_id = 1117 and sa.provider = "hubspot":* FROM activities WHERE uuid_to_bin(*8824fffb-2df7-4817-91f4-d9f896050248') = uuid; # 79933459 YES* FROM activities WHERE Uuid_to_bin('[CREDIT_CARD]-927f-4f4da2a8185c') = uuid; # 80186192 NO*EROM eon confaurazons MHEPF 3d = 1953%* FROM teans WHERE 1d = 1117;* Eoon ucene whena 3d = 30240.* fron playbooks where id = 5473;* fron playbook categonies where id = 43783:* fron playbook categories where playbook_id = 5473:*foon enn ffalde whond id = 450222.* fron cra_field values where cra_field_id = 659242:* FROM crn_field_data fdcrnfields f ON fd.crm field id = f.ioactivities a ON fd.activity_id = a.idactivity_id = 79933459f.crn_provider_id = 'hs activity_type'* FROM activity- messages:* fron text. relayswhere created at > *2826-05-91*•* fron actsivities where user_id TN (7168, 18688) and created at > 2926-85-22' orden by id desci* fron users where toam.id = 1 and 3d TN 618608, 13934, 2169):*£ron activities where usen 1a = 7169 order bu ja desc 1ini+ 18:*fnon accounts where tean 3a = 1and nane = "Colunos,.*Toon tasne whendae*foon Antáusty seanches whane usen $a = 71852.*Eoon sctkusty coanch Eilitane nhans actkusty cancch fa TM CRRRR2 RROQ2Cacaado CodoxoKick of a new aroisct. Make chunoee salestorce servico vest coveragtFixing and Validating TextRelayService TesteCnlamkhur dor ihie Kranak lohoe hlhiwt" PodnAdhotv• oNtwodtur Tasms Tox-se Whies...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86080
|
2954
|
5
|
2026-05-28T13:44:30.094702+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975870094_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_task189,8Virtual Machine Service for Docker86,8PhpStorm75,6WindowServer70,2replaydFirefoxCP Isolated Web ContentFirefox GPU HelperscreenpipeFirefoxFirefoxCP Isolated Web Contentlanguage_server_macos_armFirefoxCP Isolated Web ContentiTerm2FirefoxCP Isolated Web ContentlaunchservicesdFirefoxCP Isolated Web ContentActivity MonitorcoreaudiodSlack HelperSlack Helper (Renderer)FirefoxCP Isolated Web Contentcef_server Helper (Renderer)Control CentreWispr Flow Helper (Renderer)FirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentCPU Time22:04:59,651:28:56,712:04:05,677:40:34,875:30:12,742:02,212:23:10,283:27:33,611:45:19,1716:35,545:32,8531:29,611:02:13,6128:59,1258:57,794:21,205:43,1855:45,5113:18,0356:07,5339:56,766:24,9019:25,733:02,4710:57,032:06,5422:09,7614:37,22System:User:Idle:Threads ‹ >0lahl100% CThu 28 May 16:44:29CPUMemoryIdle Wake-Ups EnergyKindAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleCPU LOADDisk54,64%45,13%0,23%Network% GPU0,00,04,253,60,00,09,50,00,00,00,00,00,30,00,00,00,00,03,70,00,00,00,00,00,00,00,00,0Threads:Processes:SearchGPU TimePID Userrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootlukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukas...
|
NULL
|
-9032645892816202297
|
NULL
|
click
|
ocr
|
NULL
|
JAlActivity MonitorFileEditViewWindowHelpActivity JAlActivity MonitorFileEditViewWindowHelpActivity MonitorAll ProcessesProcess Name% CPUkernel_task189,8Virtual Machine Service for Docker86,8PhpStorm75,6WindowServer70,2replaydFirefoxCP Isolated Web ContentFirefox GPU HelperscreenpipeFirefoxFirefoxCP Isolated Web Contentlanguage_server_macos_armFirefoxCP Isolated Web ContentiTerm2FirefoxCP Isolated Web ContentlaunchservicesdFirefoxCP Isolated Web ContentActivity MonitorcoreaudiodSlack HelperSlack Helper (Renderer)FirefoxCP Isolated Web Contentcef_server Helper (Renderer)Control CentreWispr Flow Helper (Renderer)FirefoxCP Isolated Web ContentWispr FlowFirefoxCP Isolated Web ContentFirefoxCP Isolated Web ContentCPU Time22:04:59,651:28:56,712:04:05,677:40:34,875:30:12,742:02,212:23:10,283:27:33,611:45:19,1716:35,545:32,8531:29,611:02:13,6128:59,1258:57,794:21,205:43,1855:45,5113:18,0356:07,5339:56,766:24,9019:25,733:02,4710:57,032:06,5422:09,7614:37,22System:User:Idle:Threads ‹ >0lahl100% CThu 28 May 16:44:29CPUMemoryIdle Wake-Ups EnergyKindAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleAppleCPU LOADDisk54,64%45,13%0,23%Network% GPU0,00,04,253,60,00,09,50,00,00,00,00,00,30,00,00,00,00,03,70,00,00,00,00,00,00,00,00,0Threads:Processes:SearchGPU TimePID Userrootlukaslukas_windowserverlukaslukaslukaslukaslukaslukaslukaslukaslukaslukasrootlukaslukas_coreaudiodlukaslukaslukaslukaslukaslukaslukaslukaslukaslukas...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86078
|
2954
|
3
|
2026-05-28T13:44:21.305986+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975861305_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
7032205405598293203
|
-8704103417419559998
|
visual_change
|
hybrid
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹$0(ahlDOCKER (docker-compose)screenpipe"О 8412kibanacsearch"DOCKER881DEV (docker)₴2-zshDOCKER (docker-compose){"type" :"data"],"pid":7,'log""@timestamp"2026-05-28T13:44:12Z""tags" : ["warning""elastimessage":"Unable to reviveconnection: [URL_WITH_CREDENTIALS] "2026-05-28T13:44:12Z" , "tags" : ["warning", "elastimessage": "Noliving connections"}{"type" : "log""@timestamp":"2026-05-28T13:44:12Z", "tags" : ["error","plugins""taskManager"], "pid" :7, "message" : "Failedtopoll for work:Error: NoLiving connections"}kibana{"type":"log""@timestamp""2026-05-28T13:44:12Z""tags": ["error""elasticsearch",,"data"],"pid":7, "message";:"[ConnectionError]: getaddrinfoENOTFOUNDelasticsearchelasticsearch:9200"}kibana{"type""log""@timestamp""2026-05-28T13:44:13Z","tags": ["error""elasticsearch","data"],"pid":7,"message": "[ConnectionError]: getaddrinfo ENOTFOUNDelasticsearchelasticsearch:9200"}kibanatype'log""@timestamp2026-05-28T13:44:15Z""tags": ["error","elasticseo'-[EMAIL] (ssh)'do-release-upgrade' to upgrade to it.*** System restart required ***Lastlogin:Wed May 27 06:47:292026from 212.5.153.87lukas@jiminny-prod-bastion:~$ 0T3EU (ssh)Enable ESM Apps to receive additional futuresecurity updates.See [URL_WITH_CREDENTIALS] |100% C8• Thu 28 May 16:44:21181ec2-user@ip-10-30-140-...₴7PRODPS$I31Termonnections"}kibana{"type":"log"earch""@timestamp":"2026-05-28T13:44:18Z", "tags" : ["error","elastics,"data"], "pid":7,"message" : "[ConnectionError]: getaddrinfo ENOTFOUND elasticsearch elasticsearch:9200"'}kibana{"type" : "log","@timestamp":"2026-05-28T13:44:18Z""tags": ["warning","elasticsearch","data"], "pid" :7, "message": "Unable to revive connection: [URL_WITH_CREDENTIALS] "2026-05-28T13:44:18Z" , "tags" : ["warning", "elasti"pid":7, "message": "No living connections"}kibana{"type" :"log""@timestamp":"2026-05-28T13:44:18Z""tags": ["error","plugins""taskManager", "taskManager"],"pid" :7, "message": "Failed to pollfor work: Error: No Living connections"}docker-Lamp_12026-05-28 13:44:01 Running ['artisan'calendar:sync --dateMode=daily]2026-05-28 13:44:20 Jiminny\Jobs\Calendar\SyncCalendarEventsRUNNINGkibana{"type" :"log""@timestamp":"2026-05-28T13:44:20Z", "tags" : ["error","elasticsearch","data"], "pid" :7, "message": "[ConnectionError]: getaddrinfo ENOTFOUND elasticsearch elasticsearch:9200"}NT6FE (-zsh)lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ IFRONTENDX T7 EXT (-zsh)lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ ПEXTENSIONView in Docker Desktop• View ConfigL View Logsw Enable Watchd Detach...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
86077
|
2954
|
2
|
2026-05-28T13:44:15.259213+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975855259_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"12","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"246","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"3","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"21","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Salesforce;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\nuse Illuminate\\Events\\Dispatcher;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Log;\nuse Illuminate\\Support\\Str;\nuse Jiminny\\Component\\Country\\CountriesMap;\nuse Jiminny\\Contracts\\Acl\\PermissionEnum;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\ImportsBusinessProcessesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceBatchSyncInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\SalesforceInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteNoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SearchTaskInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SupportsObjectTypeParseInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmProfileRecordTypesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Enums\\CrmObject;\nuse Jiminny\\Events\\Activities\\Crm\\LeadConverted;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpBadRequestException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Exceptions\\NoResultsException;\nuse Jiminny\\Exceptions\\ServiceUnavailableException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Jobs\\Crm\\MatchActivitiesToNewOpportunity;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Calendar\\CalendarEvent;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Configuration;\nuse Jiminny\\Models\\Crm\\ContactRole;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Crm\\RecordType;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\TeamSettings;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\ContactRoleRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\Crm\\RecordTypeFieldValuesRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Helpers\\ArrayIterator;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldHelper;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\FieldTypeConverter;\nuse Jiminny\\Services\\Crm\\Salesforce\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\FollowupActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\LogActivityTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\RecordManipulationsTrait;\nuse Jiminny\\Services\\Crm\\Salesforce\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Utils\\CurrencyFormatter;\nuse Jiminny\\Utils\\StringUtil;\nuse Ramsey\\Uuid\\Uuid;\nuse Sentry\\Laravel\\Facade as Sentry;\n\nclass Service extends BaseService implements\n SalesforceInterface,\n SalesforceBatchSyncInterface,\n SyncCrmEntitiesInterface,\n SyncCrmProfileRecordTypesInterface,\n ImportsBusinessProcessesInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SearchTaskInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n SupportsObjectTypeParseInterface,\n RemoteNoteEntityManipulationInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncFieldsTrait;\n use DeleteObjectsTrait;\n use RecordManipulationsTrait;\n use ServiceTraits\\BatchSyncTrait;\n use FollowupActivityTrait;\n use LogActivityTrait;\n\n /**\n * Note Body Limit for the Old Note-Taking Tool\n *\n * @var int\n */\n private const int CLASSIC_NOTE_MAX_LENGTH = 32000;\n\n /**\n * Note Content Limit for the New Notes\n *\n * @var int\n */\n private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;\n\n private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';\n\n private const int CACHE_TTL = 600;\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400\n\n /**\n * @var Client\n */\n protected $client;\n\n protected PayloadBuilder $payloadBuilder;\n protected QueryHandler $queryHandler;\n\n private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n\n public function __construct(\n Client $client,\n PayloadBuilder $payloadBuilder,\n protected Dispatcher $eventDispatcher,\n private readonly CountriesMap $countriesMap,\n private readonly ProspectPhotoPathService $prospectPhotoPathService,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->payloadBuilder = $payloadBuilder;\n $this->queryHandler = app(QueryHandler::class, [\n 'client' => $this->client,\n 'logger' => $this->logger,\n ]);\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n }\n\n public function getDisplayName(): string\n {\n return 'Salesforce';\n }\n\n public function getJobDelay(): int\n {\n return 1;\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {\n $playbook = $this->getPlaybookFromActivity($activity);\n\n if ($playbook === null) {\n $this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [\n 'activity' => $activity->getId(),\n 'crm_provider_id' => $crmProviderId,\n ]);\n\n return false;\n }\n\n $objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';\n\n try {\n $record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);\n\n return ! empty($record) && ($record['IsDeleted'] ?? false) === false;\n } catch (HttpNotFoundException|HttpBadRequestException) {\n $this->logger->info('[Salesforce] Activity record not found during verification', [\n 'activity' => $activity->getId(),\n 'object_type' => $objectType,\n 'crm_provider_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n });\n }\n\n public function query(string $queryToRun, array $parameters = []): QueryIterator\n {\n // Due to poorly designed external calls, this method cannot be entirely removed\n return $this->queryHandler->query($queryToRun, $parameters);\n }\n\n /*=========== Organization Information ===============*/\n\n /**\n * Get a list of all the API Versions for the instance.\n *\n * @throws CrmException\n *\n * @return mixed\n *\n */\n public function getApiVersions()\n {\n $url = $this->config->crm_base_url . '/services/data';\n\n $response = $this->client->get($url);\n\n return json_decode($response->getBody(), true);\n }\n\n /**\n * Gets the valid recordTypes for a given Salesforce Object via the describe API.\n */\n private function getRecordTypes(string $crmObject): array\n {\n $url = $this->client->getObjectsUrl() . $crmObject . '/describe';\n\n $response = $this->client->get($url);\n $jsonResponse = json_decode($response->getBody(), true);\n\n $fields = [];\n foreach ($jsonResponse['recordTypeInfos'] as $row) {\n $fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];\n }\n\n return $fields;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n $defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::defaultTaskFields()\n : FieldDefinitions::defaultEventFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n // Setup the activity field as the default Type.\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'Type',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n\n $fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)\n ? FieldDefinitions::taskFollowupFieldsFilter()\n : FieldDefinitions::eventFollowupFieldsFilter();\n\n foreach ($fieldFilter as $eachFilter) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n /**\n * This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually\n * Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType\n * Needs to be replaced later on\n */\n public function syncField(Field $field): void\n {\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)\n ? 'activity'\n : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $metadata = $sfField['Metadata'];\n\n $field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);\n $field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);\n $field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());\n $field->is_mandatory = ($metadata['required'] === true);\n $field->length = $metadata['length'];\n $field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '\"'), 0, 191);\n $field->save();\n } else {\n $query = '\n SELECT\n Id, DataType, DeveloperName, Label, Length, Description\n FROM\n FieldDefinition\n WHERE\n DurableId = :entityName';\n\n $entityName = $field->getEntityName();\n $sfFields = $this->queryHandler->metadata($query, [\n 'entityName' => $entityName,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $convertedType = $this->convertFieldType($sfField['DataType'], $entityName);\n $label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);\n\n if ($field->isBusinessType()) {\n $label = 'Opportunity Type';\n }\n\n $field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);\n $field->label = $label;\n $field->type = $convertedType;\n $field->length = $sfField['Length'];\n $field->save();\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n private function convertFieldType(string $from, ?string $entityName = null): string\n {\n $converter = new FieldTypeConverter();\n\n return $converter->convert($from, $entityName);\n }\n\n /**\n * @inheritdoc\n */\n public function importPicklistValues(Field $field): array\n {\n $values = [];\n $fieldValues = [];\n\n try {\n if (FieldHelper::isCustomField($field)) {\n $query = '\n SELECT\n Id, Metadata, TableEnumOrId\n FROM\n CustomField\n WHERE\n DeveloperName = :fieldName\n AND\n TableEnumOrId = :fieldType\n AND\n NamespacePrefix = :namespacePrefix';\n\n // We need to constrain the field lookup to the object, in case it's used in multiple places.\n $objectType = \\in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?\n 'activity' : $field->object_type;\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'fieldName' => substr($field->crm_provider_id, 0, -\\strlen('__c')),\n 'fieldType' => ucfirst($objectType),\n // This is used to ensure we only consider the field within the org, not installed packages.\n 'namespacePrefix' => 'null',\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n $valueSet = $sfField['Metadata']['valueSet'];\n\n if ($valueSet['valueSetName'] === null) {\n // Local picklist values can be obtained easily.\n $picklistValues = $valueSet['valueSetDefinition']['value'];\n } else {\n // But for some fields, we just get the Global Value Picklist pointer so need to do more work.\n $picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);\n }\n\n // Import all active values.\n foreach ($picklistValues as $i => $sfFieldValue) {\n // Setup default value.\n if ($sfFieldValue['default']) {\n $field->update(['default_value' => $sfFieldValue['valueName']]);\n }\n\n // This comes through as null if active (lol).\n if ($sfFieldValue['isActive'] !== false) {\n $values[] = [\n 'value' => $sfFieldValue['valueName'],\n 'label' => $sfFieldValue['valueName'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['default'],\n ];\n }\n }\n } else {\n $objectFields = $this->getObjectFields($field->object_type);\n $fieldId = $field->crm_provider_id;\n\n // Only work with our field of interest.\n $objectField = array_filter($objectFields, function ($item) use ($fieldId) {\n return $item['name'] === $fieldId;\n });\n\n $objectField = array_shift($objectField);\n if (empty($objectField['picklistValues']) === false) {\n foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {\n // Skip inactive values.\n if ($sfFieldValue['active'] === false) {\n continue;\n }\n\n // Setup default value.\n if ($sfFieldValue['defaultValue']) {\n $field->update(['default_value' => $sfFieldValue['value']]);\n }\n\n $values[] = [\n 'value' => $sfFieldValue['value'],\n 'label' => $sfFieldValue['label'],\n 'sequence' => $i,\n 'is_default' => $sfFieldValue['defaultValue'],\n ];\n }\n }\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'] ?? '', 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n // Get IDs of the values to be deleted\n $valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);\n $valuesToDeleteIds = $valuesToDelete->pluck('id');\n if (! $valuesToDeleteIds->isEmpty()) {\n $recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);\n $recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());\n\n // Now safely delete from crm_field_values\n $valuesToDelete->delete();\n }\n\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n return $fieldValues;\n }\n\n /**\n * Gets values from Global Value Picklists.\n */\n private function importGlobalValuePicklistValues(string $picklistName): array\n {\n $query = '\n SELECT\n Metadata\n FROM\n GlobalValueSet\n WHERE\n DeveloperName = :picklistName\n LIMIT 1';\n\n try {\n $sfValues = $this->queryHandler->metadata($query, [\n 'picklistName' => $picklistName,\n ]);\n\n // There is always 1 result at this point.\n $sfValue = $sfValues->current();\n\n return $sfValue['Metadata']['customValue'];\n } catch (NoResultsException $noResultsException) {\n // Nothing returned.\n\n return [];\n }\n }\n\n /**\n * @inheritdoc\n */\n public function syncProfileRecordTypes(): void\n {\n $objectTypes = [\n 'lead',\n 'account',\n 'contact',\n 'opportunity',\n 'task',\n 'event',\n ];\n\n foreach ($objectTypes as $objectType) {\n try {\n $crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));\n\n foreach ($crmRecordTypes as $crmRecordType) {\n // If the record type is default and not the Master type, set this.\n if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmRecordType['recordTypeId'])\n ->first();\n\n if ($recordType) {\n $this->profile->{$objectType . '_record_type_id'} = $recordType->id;\n }\n }\n }\n } catch (HttpNotFoundException $exception) {\n Log::error('No access to ' . $objectType . ' object, skipping...');\n\n // XXX: should we log this fact somewhere?\n continue;\n }\n }\n\n if ($this->profile->isDirty()) {\n $this->profile->save();\n }\n }\n\n /**\n * Gets business processes.\n */\n public function importBusinessProcesses(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, TableEnumOrId\n FROM\n BusinessProcess\n WHERE\n TableEnumOrId IN (\\'Lead\\',\\'Opportunity\\')';\n\n try {\n $sfProcesses = $this->queryHandler->query($query);\n\n // Upsert all processes for the team.\n foreach ($sfProcesses as $sfProcess) {\n /** @var BusinessProcess $businessProcess */\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $sfProcess['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => $sfProcess['Name'],\n 'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',\n 'is_selectable' => $sfProcess['IsActive'],\n ]);\n\n $this->importBusinessProcessStages($businessProcess);\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets business process stages.\n */\n private function importBusinessProcessStages(BusinessProcess $businessProcess): void\n {\n $query = '\n SELECT\n Metadata\n FROM\n BusinessProcess\n WHERE\n Id = :processId';\n\n try {\n $stages = [];\n $sfProcessStages = $this->queryHandler->metadata($query, [\n 'processId' => $businessProcess->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfProcessStage = $sfProcessStages->current();\n\n // Upsert all processes for the team.\n foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {\n $sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: \"%2C\" becomes \",\" etc.\n\n $stage = $businessProcess->crm->stages()\n // This MUST match on label because this API doesn't use API Name.\n ->where('label', $sanitizedName)\n ->where('type', $businessProcess->type)\n ->where('is_selectable', 1)\n ->first();\n\n if ($stage) {\n $stages[] = $stage->id;\n }\n }\n\n $businessProcess->stages()->sync($stages);\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * Gets record types.\n */\n public function importRecordTypes(): void\n {\n $query = '\n SELECT\n Id, IsActive, Name, BusinessProcessId, SobjectType\n FROM\n RecordType';\n\n try {\n $sfRecordTypes = $this->queryHandler->query($query);\n\n // Upsert all record types for the process.\n foreach ($sfRecordTypes as $sfRecordType) {\n $businessProcess = null;\n if ($sfRecordType['BusinessProcessId']) {\n $businessProcess = $this->config->businessProcesses()\n ->where('crm_provider_id', $sfRecordType['BusinessProcessId'])\n ->first();\n }\n\n /** @var RecordType $recordType */\n $recordType = $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $sfRecordType['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'type' => mb_strtolower($sfRecordType['SobjectType']),\n 'name' => $sfRecordType['Name'],\n 'is_selectable' => $sfRecordType['IsActive'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n $this->importRecordTypeFieldValues($recordType);\n }\n } catch (NoResultsException $noResultsException) {\n // Do nothing.\n }\n }\n\n /**\n * Import record type - field value mappings. This only works for standard fields.\n */\n private function importRecordTypeFieldValues(RecordType $recordType): void\n {\n try {\n $query = '\n SELECT\n Metadata\n FROM\n RecordType\n WHERE\n Id = :recordTypeId';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'recordTypeId' => $recordType->crm_provider_id,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Sync field metadata.\n $picklists = $sfField['Metadata']['picklistValues'];\n\n foreach ($picklists as $picklist) {\n $field = $this->config->fields()->where([\n 'type' => Field::TYPE_PICKLIST,\n 'object_type' => $recordType->type,\n 'crm_provider_id' => $picklist['picklist'],\n ])->first();\n\n if ($field) {\n $fieldValues = [];\n\n foreach ($picklist['values'] as $value) {\n // Must decode: \"%2C\" becomes \",\" etc.\n $fieldValue = $field->values()\n ->where('value', urldecode($value['valueName']))\n ->first();\n\n if ($fieldValue) {\n $fieldValues[] = $fieldValue->id;\n }\n }\n\n $recordType->fieldValues()->sync($fieldValues);\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $params = [];\n $missingStage = null;\n if ($types === null) {\n $types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];\n }\n\n foreach ($types as $type) {\n if ($type === Stage::TYPE_LEAD) {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, SortOrder\n FROM\n LeadStatus';\n } else {\n $query = '\n SELECT\n Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability\n FROM\n OpportunityStage';\n }\n\n if ($missingStageName) {\n $escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);\n\n $query .= ' WHERE ApiName = :stageName';\n\n $params = [\n 'stageName' => $escapedStageName,\n ];\n }\n\n try {\n $sfStages = $this->queryHandler->query($query, $params);\n } catch (NoResultsException $exception) {\n $sfStages = [];\n }\n\n $missingStage = null;\n\n // Upsert all stages for the team.\n foreach ($sfStages as $sfStage) {\n $selectable = true;\n if (array_key_exists('IsActive', $sfStage)) {\n $selectable = $sfStage['IsActive'];\n }\n\n $this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);\n\n $stage = $this->config->stages()->updateOrCreate([\n 'crm_provider_id' => $sfStage['Id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),\n 'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),\n 'type' => $type,\n 'sequence' => $sfStage['SortOrder'] ?? 0,\n 'is_selectable' => $selectable,\n 'probability' => $sfStage['DefaultProbability'] ?? null,\n ]);\n\n if ($missingStageName && $missingStageName === $sfStage['ApiName']) {\n $missingStage = $stage;\n }\n }\n\n if ($missingStageName && $missingStage === null) {\n // If they requested a stage that still doesn't exist, it must be inactive so lazy create it.\n $missingStage = $this->config->stages()->create([\n 'crm_provider_id' => Uuid::uuid4(),\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($missingStageName, 0, 50),\n 'label' => mb_strimwidth($missingStageName, 0, 191),\n 'type' => $type,\n 'sequence' => 0,\n 'is_selectable' => 0,\n ]);\n }\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('lead');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Lead\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfLeads = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfLeads as $sfLead) {\n // Only sync if previously imported.\n if ($this->hasLead($sfLead['Id'])) {\n $this->importLead($sfLead);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncLead(string $crmId): ?Lead\n {\n $fields = $this->getAllFieldsAsArray('lead');\n\n $sfLead = $this->getRecord('Lead', $crmId, $fields);\n\n return $this->importLead($sfLead);\n }\n\n private function importLead($crmData): ?Lead\n {\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['Status'])) {\n // Get the current stage.\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['Status'])\n ->where('type', Stage::TYPE_LEAD)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);\n }\n }\n\n // If we have no way of importing this, just return null :(\n if ($stage === null) {\n return null;\n }\n\n $countryCode = $crmData['CountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country name.\n if ($countryCode === null && empty($crmData['Country']) !== false) {\n $countryCode = $this->convertCountryNameToCode($crmData['Country']);\n }\n\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n\n $mobilePhone = null;\n if (empty($crmData['MobilePhone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['MobilePhone'], 0, 25);\n $mobilePhone = phone_e164($countryCode, $number);\n }\n\n $convertedDate = null;\n $convertedAccount = null;\n $convertedOpportunity = null;\n $convertedContact = null;\n\n if ($crmData['IsConverted'] == 'true') {\n $convertedDate = $crmData['ConvertedDate'];\n\n if (empty($crmData['ConvertedAccountId']) === false) {\n $convertedAccount = $this->config\n ->accounts()\n ->where('crm_provider_id', $crmData['ConvertedAccountId'])\n ->first();\n\n if ($convertedAccount === null) {\n try {\n $convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedOpportunityId']) === false) {\n $convertedOpportunity = $this->config\n ->opportunities()\n ->where('crm_provider_id', $crmData['ConvertedOpportunityId'])\n ->first();\n\n if ($convertedOpportunity === null) {\n try {\n $convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n\n if (empty($crmData['ConvertedContactId']) === false) {\n $convertedContact = $this->team\n ->crm\n ->contacts()\n ->where('crm_provider_id', $crmData['ConvertedContactId'])\n ->first();\n\n if ($convertedContact === null) {\n try {\n $convertedContact = $this->syncContact($crmData['ConvertedContactId']);\n } catch (HttpNotFoundException $exception) {\n // Probably the user has no permissions to access the converted data.\n }\n }\n }\n }\n\n if (empty($crmData['Company'])) {\n $company = 'Unknown';\n } else {\n $company = mb_strimwidth($crmData['Company'], 0, 191);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? '',\n 'company' => $company,\n 'domain' => $domain,\n 'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',\n 'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,\n 'phone' => $parsedNumber['phone'],\n 'ext' => $parsedNumber['ext'] ?? null,\n 'mobile_phone' => $mobilePhone,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Lead::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'stage_id' => $stage->id,\n 'record_type_id' => null,\n 'converted_at' => $convertedDate,\n 'converted_account_id' => $convertedAccount->id ?? null,\n 'converted_opportunity_id' => $convertedOpportunity->id ?? null,\n 'converted_contact_id' => $convertedContact->id ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);\n\n /** @var Lead $lead */\n $lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {\n $this->eventDispatcher->dispatch(new LeadConverted($lead));\n }\n\n $this->handleObjectDeletion($lead, $crmData);\n\n if ($lead->trashed()) {\n return null;\n }\n\n return $lead;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccounts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('account');\n\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Account\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfAccounts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfAccounts as $sfAccount) {\n // Only sync if previously imported.\n if ($this->hasAccount($sfAccount['Id'])) {\n $this->importAccount($sfAccount);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncAccount(string $crmId): ?Account\n {\n $fields = $this->getAllFieldsAsArray('account');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfAccount = $this->getRecord('Account', $crmId, $fields);\n\n return $this->importAccount($sfAccount);\n }\n\n private function importAccount($crmData): ?Account\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);\n\n return null;\n }\n\n $countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;\n\n // Salesforce allows custom \"countries\" to be created. Disregard these.\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n // If we have no country code, try to parse it from the country names.\n if ($countryCode === null && empty($crmData['BillingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);\n }\n\n if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);\n }\n\n if (empty($crmData['Phone']) === false) {\n // Trim to our width and attempt to parse it.\n $number = mb_strimwidth($crmData['Phone'], 0, 25);\n $parsedNumber = parsePhoneNumber($countryCode, $number);\n } else {\n $parsedNumber = [];\n }\n\n $industry = null;\n if (empty($crmData['Industry']) === false) {\n $industry = mb_strimwidth($crmData['Industry'], 0, 40);\n }\n\n $domain = null;\n if (empty($crmData['Website']) === false) {\n $domain = mb_strimwidth($crmData['Website'], 0, 191);\n $domain = StringUtil::resolveDomain($domain);\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'],\n 'name' => mb_strimwidth($crmData['Name'], 0, 191),\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Account::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'industry' => $industry,\n 'domain' => $domain,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $parsedNumber['ext'] ?? null,\n 'country_code' => $countryCode,\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);\n\n /** @var Account $account */\n $account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($account, $crmData);\n\n return $account->trashed() ? null : $account;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOpportunities(array $parameters, ?string $strategy = null): int\n {\n $strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);\n\n $syncCount = 0;\n $logParams = $parameters;\n $parameters['profile'] = $this->profile;\n $logParams['user'] = $this->profile->getUserId();\n\n if (count($strategies) > 1) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [\n 'teamId' => $this->team->getUuid(),\n 'params' => $logParams,\n 'strategies_count' => count($strategies),\n ]);\n }\n\n foreach ($strategies as $syncStrategy) {\n $name = $syncStrategy->getStrategyName();\n\n try {\n $sfOpportunities = $syncStrategy->fetchOpportunities($parameters);\n $totalRecords = $sfOpportunities->count();\n\n foreach ($sfOpportunities as $sfOpportunity) {\n $this->importOpportunity($sfOpportunity);\n $syncCount++;\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $noResultsException->getMessage(),\n ]);\n } catch (CrmException $crmException) {\n // Nothing to sync.\n $this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->getUuid(),\n 'name' => $name,\n 'params' => $logParams,\n 'reason' => $crmException->getMessage(),\n ]);\n }\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);\n\n // debug to see how if count of opportunities reaches 1000\n if ($syncCount >= 1000) {\n $this->logger->info(\n '[' . $this->getDisplayName() . '] Sync Opportunities - count warning',\n [\n 'team_id' => $this->team->getId(),\n 'params' => $logParams,\n 'count' => $syncCount,\n 'strategies_count' => count($strategies),\n 'total_records' => $totalRecords ?? null,\n ]\n );\n }\n\n return $syncCount;\n }\n\n\n /**\n * @inheritdoc\n */\n public function syncOpportunity(string $crmId): ?Opportunity\n {\n $strategy = $this->opportunitySyncStrategyResolver->resolve(\n $this->config,\n OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY\n );\n\n $parameters = [\n 'profile' => $this->profile,\n 'crm_id' => $crmId,\n ];\n\n try {\n $sfOpportunity = $strategy->fetchOpportunities($parameters);\n } catch (HttpNotFoundException $e) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n ]);\n\n return null;\n } catch (CrmException $crmException) {\n $this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [\n 'teamId' => $this->team->id_string,\n 'crmId' => $crmId,\n 'exception' => $crmException->getMessage(),\n ]);\n\n return null;\n }\n\n if ($sfOpportunity instanceof ArrayIterator) {\n return $this->importOpportunity($sfOpportunity->getItems());\n }\n\n return $this->importOpportunity($sfOpportunity);\n }\n\n /**\n * @throws HttpNotFoundException\n */\n private function importOpportunity($crmData): ?Opportunity\n {\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $account = null;\n if (empty($crmData['AccountId']) === false) {\n /** @var ?Account $account */\n $account = $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first();\n\n if ($account === null) {\n $account = $this->syncAccount($crmData['AccountId']);\n }\n }\n\n $userId = $profile?->getUserId() ?? $account?->getUserId();\n if ($userId === null) {\n $this->logger->error('[Salesforce] | Skip import, no user_id found', [\n 'id' => $crmData['Id'],\n ]);\n\n return null;\n }\n\n /** @var ?Stage $stage */\n $stage = null;\n if (isset($crmData['StageName'])) {\n $stage = $this->config\n ->stages()\n ->where('name', $crmData['StageName'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->orderBy('is_selectable', 'DESC')\n ->orderBy('id')\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $crmData['StageName']);\n }\n }\n\n $recordType = null;\n if (empty($crmData['RecordTypeId']) === false) {\n /** @var ?RecordType $recordType */\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $crmData['RecordTypeId'])\n ->first();\n }\n\n $createdDate = null;\n if (empty($crmData['CreatedDate']) === false) {\n $createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n }\n\n $closeDate = null;\n if (empty($crmData['CloseDate']) === false) {\n $closeDate = Carbon::parse($crmData['CloseDate'])->format('Y-m-d');\n }\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $userId,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => mb_strimwidth($crmData['Name'] ?? '', 0, 128),\n 'value' => $crmData[$valueFieldName],\n 'currency_code' => CurrencyFormatter::formatCode($crmData['CurrencyIsoCode'] ?? null),\n 'close_date' => $closeDate,\n 'is_closed' => $crmData['IsClosed'],\n 'is_won' => $crmData['IsWon'],\n 'stage_id' => $stage?->id ?? null,\n 'record_type_id' => $recordType->id ?? null,\n 'remotely_created_at' => $createdDate,\n 'probability' => $crmData['Probability'] ?? null,\n 'forecast_category' => $crmData['ForecastCategoryName'] ?? null,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->opportunities(), $crmData['Id']);\n\n // Do not allow locked DB tables & other errors\n // to interrupt the process of reverting the trashed opportunities\n try {\n /** @var Opportunity $opportunity */\n $opportunity = $this->config->opportunities()\n ->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n // import external fields into crm_field_data if present\n $crmFields = $this->getOpportunitySyncableFields();\n\n $this->importOpportunityCrmFieldData($crmData, $crmFields, $opportunity->id);\n\n $this->handleObjectDeletion($opportunity, $crmData);\n\n if ($opportunity->trashed()) {\n return null;\n }\n\n if ($opportunity->wasRecentlyCreated) {\n MatchActivitiesToNewOpportunity::dispatch($opportunity->getId());\n }\n\n return $opportunity;\n } catch (Exception $exception) {\n Sentry::captureException($exception);\n\n $this->logger->error('[Salesforce] importOpportunity failure.', [\n 'crm_provider_id' => $crmData['Id'],\n 'team_id' => $this->team->id,\n 'exception' => $exception->getMessage(),\n ]);\n\n $this->handleEntityDeletionByProviderId($this->config->opportunities(), $crmData);\n }\n\n return null;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContacts(Carbon $since, ?Carbon $to = null): int\n {\n $syncCount = 0;\n $fields = $this->getAllFieldsAsArray('contact');\n if (\\in_array('Id', $fields, true) === false) {\n return $syncCount;\n }\n\n $query = '\n SELECT ' . rtrim(implode(',', $fields), ',') . '\n FROM Contact\n WHERE LastModifiedDate > :since\n ORDER BY LastModifiedDate ASC';\n\n try {\n $sfContacts = $this->queryHandler->query($query, [\n 'since' => $since->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($sfContacts as $sfContact) {\n // Only sync if previously imported.\n if ($this->hasContact($sfContact['Id'])) {\n $this->importContact($sfContact);\n $syncCount++;\n }\n }\n } catch (NoResultsException $noResultsException) {\n // Nothing to sync.\n }\n\n $this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::CONTACT);\n\n return $syncCount;\n }\n\n /**\n * @inheritdoc\n */\n public function syncContact(string $crmId): ?Contact\n {\n $fields = $this->getAllFieldsAsArray('contact');\n if (! in_array('Id', $fields, true)) {\n $this->logger->info('[Salesforce] Sync contact cancelled. Fields are not available.', [\n 'crmId' => $crmId,\n 'userId' => $this->profile->getUserId(),\n ]);\n\n return null;\n }\n\n $sfContact = $this->getRecord('Contact', $crmId, $fields);\n\n return $this->importContact($sfContact);\n }\n\n private function importContact($crmData): ?Contact\n {\n if (! empty($crmData['IsDeleted'])) {\n $this->handleEntityDeletionByProviderId($this->config->contacts(), $crmData);\n\n return null;\n }\n\n $account = $this->resolveContactAccount($crmData);\n $countryCode = $this->resolveContactCountryCode($crmData, $account);\n [$parsedNumber, $ext] = $this->parseContactPhone($countryCode, $crmData);\n $mobileNumber = $this->parseContactMobile($countryCode, $crmData);\n $createdDate = empty($crmData['CreatedDate'])\n ? null\n : Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');\n $profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);\n\n $data = [\n 'team_id' => $this->team->id,\n 'account_id' => $account->id ?? null,\n 'user_id' => $profile?->user_id,\n 'owner_id' => $crmData['OwnerId'] ?? null,\n 'name' => ($crmData['Name'] ?? null) !== null ? mb_strimwidth($crmData['Name'], 0, 100) : '',\n 'title' => ($crmData['Title'] ?? null) !== null ? mb_strimwidth($crmData['Title'], 0, 128) : null,\n 'email' => ($crmData['Email'] ?? null) !== null ? mb_strimwidth($crmData['Email'], 0, 191) : null,\n 'country_code' => $countryCode,\n 'phone' => $parsedNumber['phone'] ?? null,\n 'ext' => $ext,\n 'mobile_phone' => $mobileNumber,\n 'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(\n crmConfiguration: $this->config,\n crmProviderId: $crmData['Id'],\n modelType: Contact::class,\n fileName: $crmData['Id'],\n avatarText: $crmData['Name']\n ),\n 'remotely_created_at' => $createdDate,\n ];\n\n $this->restoreAnyTrashedEntity($this->config->contacts(), $crmData['Id']);\n\n /** @var Contact $contact */\n $contact = $this->config->contacts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);\n\n $this->handleObjectDeletion($contact, $crmData);\n\n return $contact->trashed() ? null : $contact;\n }\n\n private function resolveContactAccount(array $crmData): ?Account\n {\n if (! isset($crmData['AccountId'])) {\n return null;\n }\n\n return $this->config->accounts()\n ->where('crm_provider_id', (string) $crmData['AccountId'])\n ->first() ?? $this->syncAccount($crmData['AccountId']);\n }\n\n private function resolveContactCountryCode(array $crmData, ?Account $account): ?string\n {\n $countryCode = $crmData['MailingCountryCode'] ?? null;\n\n if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {\n $countryCode = null;\n }\n\n if ($countryCode === null && empty($crmData['MailingCountry']) === false) {\n $countryCode = $this->convertCountryNameToCode($crmData['MailingCountry'])\n ?? ($account?->country_code);\n }\n\n return $countryCode;\n }\n\n private function parseContactPhone(?string $countryCode, array $crmData): array\n {\n if (empty($crmData['Phone'])) {\n return [[], null];\n }\n\n $parsedNumber = parsePhoneNumber($countryCode, Str::limit($crmData['Phone'], 25, ''));\n $ext = empty($parsedNumber['ext']) ? null : Str::limit($parsedNumber['ext'], 10, '');\n\n return [$parsedNumber, $ext];\n }\n\n private function parseContactMobile(?string $countryCode, array $crmData): ?string\n {\n if (empty($crmData['MobilePhone'])) {\n return null;\n }\n\n return Str::limit(phone_e164($countryCode, $crmData['MobilePhone']), 25, '');\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n $fields = [\n 'InstanceName',\n 'OrganizationType',\n 'IsSandbox',\n ];\n\n $orgValues = $this->getRecord('Organization', $this->config->crm_provider_id, $fields);\n\n $edition = null;\n switch ($orgValues['OrganizationType']) {\n case 'Developer Edition':\n $edition = Configuration::EDITION_DEVELOPER;\n\n break;\n\n case 'Professional Edition':\n $edition = Configuration::EDITION_PROFESSIONAL;\n\n break;\n\n case 'Enterprise Edition':\n $edition = Configuration::EDITION_ENTERPRISE;\n\n break;\n }\n\n $this->config->edition = $edition;\n $this->config->instance = $orgValues['InstanceName'];\n\n // XXX: How can this state be possible?\n if ($this->config->version === null) {\n $this->config->version = Client::MIN_API_VERSION;\n }\n\n $installedVersion = $this->getInstalledAppVersion();\n if ($installedVersion !== null) {\n $installedVersion = (string) $this->getInstalledAppVersion();\n }\n\n $this->config->installed_app_version = $installedVersion;\n\n $this->config->save();\n }\n\n public function getInstalledAppVersion(): ?string\n {\n try {\n $query = '\n SELECT\n SubscriberPackageVersion.MajorVersion,\n SubscriberPackageVersion.MinorVersion,\n SubscriberPackageVersion.PatchVersion,\n SubscriberPackageVersion.BuildNumber\n FROM\n InstalledSubscriberPackage\n WHERE\n SubscriberPackageId = :packageId\n ';\n\n $sfFields = $this->queryHandler->metadata($query, [\n 'packageId' => self::INSTALLED_PACKAGE_ID,\n ]);\n\n // There is always 1 result at this point.\n $sfField = $sfFields->current();\n\n // Grab version number.\n $version = $sfField['SubscriberPackageVersion']['MajorVersion'] .\n $sfField['SubscriberPackageVersion']['MinorVersion'] .\n $sfField['SubscriberPackageVersion']['PatchVersion'] .\n $sfField['SubscriberPackageVersion']['BuildNumber'];\n } catch (\\Exception) {\n $version = null;\n }\n\n return $version;\n }\n\n /**\n * Store transcripts as note.\n *\n * @throws \\Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For SF we also check if Log Notes is enabled.\n if ($this->profile->log_notes === Profile::LOG_NOTE_NONE) {\n return;\n }\n\n if ($activity->opportunity_id && $activity->prospect === null) {\n return;\n }\n\n try {\n $transcriptionData = $this->generateTranscription($activity);\n\n $noteMaxLength = $this->profile->log_notes === Profile::LOG_NOTE_ENHANCED\n ? self::ENHANCED_NOTE_MAX_LENGTH\n : self::CLASSIC_NOTE_MAX_LENGTH;\n\n $title = 'Transcript for ';\n $title .= $activity->title ?? $activity->activity_title;\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($transcriptionData, 0, $noteMaxLength);\n\n if ($activity->opportunity_id) {\n $objectId = $activity->opportunity->crm_provider_id;\n } else {\n $objectId = $activity->prospect->crm_provider_id;\n }\n\n $noteId = $this->saveNote($title, $body, $objectId);\n\n // Store crm logged id in transcription.\n $transcription = $activity->getTranscription();\n $transcription->crm_activity_id = $noteId;\n $transcription->save();\n } catch (\\Exception $e) {\n \\Sentry::captureException($e);\n }\n }\n\n public function saveNote(string $title, string $body, string $objectId, ?NoteObject $noteObject = null): ?string\n {\n $noteId = null;\n\n try {\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $noteId = $this->buildEnhancedNote($title, $body, $objectId);\n } else {\n $noteId = $this->buildClassicNote($title, $body, $objectId);\n }\n } catch (HttpNotFoundException $exception) {\n // The profile not having access to create Enhanced Notes. Set their preference to Classic.\n if ($this->profile->log_notes === Profile::LOG_NOTE_ENHANCED) {\n $this->profile->update([\n 'log_notes' => Profile::LOG_NOTE_CLASSIC,\n ]);\n }\n }\n\n return $noteId;\n }\n\n /**\n * This is using the \"Enhanced\" Notes feature, NOT the \"Notes & Attachments\" feature being deprecated.\n *\n * @url https://salesforce.stackexchange.com/questions/104408/how-can-i-create-an-account-note-or-contact-note-via-api-that-is-visible-in-sale\n */\n private function buildEnhancedNote(string $title, string $body, string $objectId): string\n {\n // Decode stored entities, escape HTML (without quoting), then convert line breaks for Salesforce formatting\n $decodedBody = html_entity_decode($body, ENT_QUOTES | ENT_HTML5);\n $sanitizedBody = htmlspecialchars($decodedBody, ENT_NOQUOTES, 'UTF-8', false);\n $content = nl2br($sanitizedBody, false);\n $note = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'Title' => $title,\n 'Content' => base64_encode($content),\n ];\n\n $noteId = $this->createRecord('ContentNote', $note);\n\n $link = [\n 'ContentDocumentId' => $noteId,\n 'LinkedEntityId' => $objectId,\n 'ShareType' => 'I',\n ];\n\n $this->createRecord('ContentDocumentLink', $link);\n\n return $noteId;\n }\n\n private function buildClassicNote(string $title, string $body, string $objectId): string\n {\n if (in_array($this->parseObjectType($objectId), [Field::OBJECT_TASK, Field::OBJECT_EVENT])) {\n $this->logger->info('[Salesforce] Summary not sent', [\n 'profile_id' => $this->profile->id,\n 'objectId' => $objectId,\n 'reason' => 'Classical Note does not support Task/Event relation',\n ]);\n\n return '';\n }\n\n $titleTrimmed = null;\n\n if (mb_strlen($title) > 80) {\n $titleTrimmed = substr($title, 0, 77) . '...';\n }\n $payload = [\n 'OwnerId' => $this->profile->crm_provider_id,\n 'IsPrivate' => false,\n 'Title' => $titleTrimmed ?? $title,\n 'Body' => $titleTrimmed ? $title . PHP_EOL . $body : $body,\n 'ParentId' => $objectId,\n ];\n\n return $this->createRecord('Note', $payload);\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n if ($this->profile === null) {\n return [];\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $limitValues = ['limit' => $this->limit, 'offset' => $this->offset];\n $sosl = $queryBuilder->buildFindQuery($name, $scopes, $limitValues);\n\n $this->logger->info('[Salesforce] Find prospects', [\n 'profile_id' => $this->profile->id,\n 'sosl_query' => $sosl,\n 'search_string' => $name,\n 'scopes' => $scopes,\n ]);\n\n $data = Cache::remember($this->profile->id . $sosl, self::CACHE_TTL, function () use ($sosl) {\n $data = [];\n\n try {\n // Hit remote API.\n $objects = $this->queryHandler->search($sosl);\n\n // Build mapped list.\n foreach ($objects as $object) {\n $type = strtolower($object['attributes']['type']);\n\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n 'crmUrl' => $this->generateProviderUrl($object['Id'], $type),\n ];\n\n switch ($type) {\n case 'lead':\n if (empty($object['Company']) === false) {\n $record['organization'] = $object['Company'];\n }\n\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_LEAD)\n ->where('name', $object['Status'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_LEAD], $object['Status']);\n }\n\n if ($stage) {\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n }\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n break;\n\n case 'account':\n if (empty($object['Industry']) === false) {\n $record['industry'] = $object['Industry'];\n $record['detailsLine'] = $object['Industry'];\n }\n if (! empty($object['PersonEmail'])) {\n $record['detailsLine'] = $object['PersonEmail'];\n }\n\n break;\n\n case 'contact':\n // For contacts, we should try and fetch their account name too.\n if ($object['AccountId']) {\n // Cheaper to get this locally.\n $account = $this->config->accounts()\n ->where('crm_provider_id', $object['AccountId'])\n ->first(['name']);\n\n if ($account) {\n $record['organization'] = $account->name;\n }\n }\n\n if (! empty($object['IsPersonAccount']) && $object['Email']) {\n $record['detailsLine'] = $object['Email'];\n } else {\n if (empty($object['Title']) === false) {\n $record['title'] = $object['Title'];\n }\n }\n\n break;\n }\n\n // Add phone numbers to record.\n if (empty($object['Phone']) === false && $object['Phone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['Phone'],\n 'nationalFormat' => phone_national($this->profile->user->country_code, $object['Phone']),\n 'type' => 'phone',\n ];\n }\n\n if (empty($object['MobilePhone']) === false && $object['MobilePhone']) {\n $record['phoneNumbers'][] = [\n 'number' => $object['MobilePhone'],\n 'nationalFormat' => phone_national(\n $this->profile->user->country_code,\n $object['MobilePhone']\n ),\n 'type' => 'mobile',\n ];\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n $data = [];\n }\n\n return $data;\n });\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\n if ($crmAccountId === null) {\n return $data;\n }\n\n if ($userId) {\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->findProfileByUserId($this->config, $userId);\n\n $ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;\n }\n\n try {\n // Perhaps their profile has no opportunity permissions.\n if ($this->profile === null || $this->profile->opportunity_fields === null) {\n return $data;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildFindOpportunitiesQuery();\n\n $objects = $this->queryHandler->query($query, ['accountId' => $crmAccountId]);\n\n foreach ($objects as $object) {\n $record = [\n 'crmId' => $object['Id'],\n 'name' => $object['Name'],\n 'won' => $object['IsWon'],\n 'closed' => $object['IsClosed'],\n ];\n\n $valueFieldName = 'Amount';\n if ($this->config->opportunity_value_field_id) {\n $valueFieldName = $this->config->opportunityValueField->crm_provider_id;\n }\n\n if (empty($object[$valueFieldName]) === false) {\n $currency = $object['CurrencyIsoCode'] ?? $this->config->default_currency;\n $value = formatCurrency($object[$valueFieldName], $currency);\n\n $record += [\n 'value' => $value,\n ];\n }\n\n $stage = $this->config->stages()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->where('name', $object['StageName'])\n ->first();\n\n // Lazy create the stage.\n if ($stage === null) {\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $object['StageName']);\n }\n\n $record += [\n 'stage' => [\n 'id' => $stage->id_string,\n 'name' => $stage->name,\n ],\n ];\n\n if (empty($object['RecordTypeId']) === false) {\n $recordType = $this->config->recordTypes()\n ->where('crm_provider_id', $object['RecordTypeId'])\n ->first();\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n }\n\n if ($ownerId && isset($object['OwnerId']) && $object['OwnerId'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n } catch (NoResultsException $e) {\n return $data;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n public function getContactRolesFromCrm(?Carbon $since = null): array\n {\n $roles = [];\n\n if ($this->profile === null) {\n return $roles;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n\n $query = $queryBuilder->buildGetContactRolesQuery($since);\n\n try {\n $objects = $this->queryHandler->query($query);\n\n foreach ($objects as $object) {\n $roles[] = [\n 'id' => $object['Id'],\n 'contactId' => $object['ContactId'],\n 'opportunityId' => $object['OpportunityId'],\n 'ownerId' => $object['Opportunity']['OwnerId'] ?? null,\n 'isPrimary' => $object['IsPrimary'],\n 'role' => $object['Role'],\n ];\n }\n } catch (NoResultsException) {\n // Just return an empty array.\n $this->logger->info('[Salesforce] No contact roles found', [\n 'since' => $since?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n }\n\n return $roles;\n }\n\n public function syncContactRoles(Carbon $since): int\n {\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n $crmContactRoles = $this->getContactRolesFromCrm(since: $since);\n $syncCount = 0;\n $contactRoles = [];\n\n foreach ($crmContactRoles as $crmContactRole) {\n $contactRoles[] = $this->importContactRole($crmContactRole);\n $syncCount++;\n }\n\n $contactRoleRepository->saveContactRoles($contactRoles);\n\n $this->syncRemotelyDeletedContactRoles();\n\n return $syncCount;\n }\n\n private function importContactRole(array $contactRole): array\n {\n $contact = $this->config->contacts()\n ->where('crm_provider_id', $contactRole['contactId'])\n ->first();\n\n if ($contact === null) {\n $contact = $this->syncContact($contactRole['contactId']);\n }\n\n $opportunity = $this->config->opportunities()\n ->where('crm_provider_id', $contactRole['opportunityId'])\n ->first();\n\n if ($opportunity === null) {\n $opportunity = $this->syncOpportunity($contactRole['opportunityId']);\n }\n\n $role = null;\n if (! empty($contactRole['role'])) {\n $role = mb_strimwidth($contactRole['role'], 0, 191);\n }\n\n return [\n 'crm_configuration_id' => $this->config->getId(),\n 'contact_id' => $contact->getId(),\n 'crm_provider_id' => $contactRole['id'],\n 'subject_type' => ContactRole::SUBJECT_TYPE_OPPORTUNITY,\n 'subject_id' => $opportunity->getId(),\n 'is_primary' => $contactRole['isPrimary'],\n 'role' => $role,\n ];\n }\n\n protected function syncRemotelyDeletedContactRoles(): bool\n {\n try {\n $deletedRemotely = $this->queryHandler->queryDeleted('OpportunityContactRole');\n } catch (NoResultsException $e) {\n return false;\n }\n\n $deletedOpportunities = $deletedRemotely->getResults();\n $deletedIds = array_column($deletedOpportunities, 'id');\n\n $contactRoleRepository = app(ContactRoleRepository::class);\n\n foreach (array_chunk($deletedIds, self::HARD_DELETE_CHUNK) as $chunk) {\n $contactRoleRepository->deleteContactRoles($chunk);\n\n $this->logger->info('[' . $this->getDisplayName() . '] Remotely deleted opportunities synced', [\n 'teamId' => $this->team->id_string,\n 'remotelyDeletedOpportunities' => $chunk,\n 'count' => count($chunk),\n ]);\n }\n\n return true;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($this->profile->user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_TASK),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any open call for that user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Task\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsDeleted = false\n AND IsClosed = false\n AND (';\n\n if ($objectType === 'account') {\n // This covers tasks tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getEvents(string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = $objects = [];\n $user = $this->profile?->user;\n if ($this->profile === null || $user === null) {\n return $data;\n }\n\n $hasWho = \\in_array($objectType, ['lead', 'contact']);\n $playbook = $this->getPlaybook($user);\n $fields = array_merge(\n $this->profile->getFieldsAsArray(parent::OBJECT_EVENT),\n $playbook && $playbook->activityField ? [$playbook->activityField->crm_provider_id] : []\n );\n\n // Query should default to any event starting in the last week and ending up until today owned by the user.\n $query = '\n SELECT ' . implode(',', array_unique($fields)) . '\n FROM Event\n WHERE OwnerId = :ownerId\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= LAST_N_DAYS:7\n AND EndDateTime <= TODAY\n AND (';\n\n if ($objectType === 'account') {\n // This covers events tied to a related contact or opportunity too.\n $query .= '\n AccountId = :accountId';\n }\n\n if ($hasWho) {\n $query .= '\n WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($opportunityId) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ' ) ORDER BY LastModifiedDate DESC';\n\n try {\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $this->profile->crm_provider_id,\n 'whoId' => $objectId,\n 'whatId' => $opportunityId,\n 'accountId' => $objectId,\n ]);\n } catch (NoResultsException $e) {\n return $data;\n } finally {\n $this->logger->debug(sprintf('[Salesforce] Found %s tasks for query \"%s\"', count($objects), $query));\n }\n\n foreach ($objects as $object) {\n $dueDate = $object['StartDateTime'] ? Carbon::parse($object['StartDateTime'])->toIso8601String() : null;\n\n $data[] = [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'due' => $dueDate,\n 'type' => $object[$playbook->activityField->crm_provider_id],\n ];\n }\n\n return $data;\n }\n\n /**\n * Try to find CRM Objects using email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchExactlyByEmail(string $email, ?int $userId = null): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($email, Field::TYPE_EMAIL);\n if ($sosl === null) {\n return null;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $email,\n QueryHandler::PRIORITISE_EMAIL\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException $e) {\n // Try the account next.\n if ($this->profile->account_fields === null) {\n return null;\n }\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n // SF improved search - strip the domain extension, min domain name length 4\n return $this->getCompanyNameFromEmail(email: $email, minNameLength: 4);\n }\n\n /**\n * Try to find CRM objects using domain name of the email address\n *\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByDomain(string $domain, ?int $userId = null): ?array\n {\n $companyName = $domain;\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByDomainQuery($companyName);\n\n try {\n $objects = $this->queryHandler->search($sosl);\n\n $data = $this->convertCrmData($objects, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (NoResultsException) {\n return null;\n }\n }\n\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n // Don't bother looking up numbers that are masked.\n if (str_contains($phone, '**')) {\n return null;\n }\n\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $phoneNational = phone_national(null, $phone) ?? '';\n $possiblePhoneFormats = collect([\n preg_replace('/\\D/', '', ltrim($phone, '0+')),\n preg_replace('/\\D/', '', $phoneNational),\n formatDashPhoneNumber($phone),\n $phoneNational,\n ])\n ->filter() // Removes null and empty strings\n ->unique()\n ->values();\n\n foreach ($possiblePhoneFormats as $phone) {\n $sosl = $queryBuilder->buildMatchByQuery($phone, Field::TYPE_PHONE);\n if ($sosl === null) {\n continue;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $phone,\n QueryHandler::PRIORITISE_PHONE\n );\n\n return $this->convertCrmData($objects, $userId);\n } catch (NoResultsException) {\n continue;\n }\n }\n\n return null;\n }\n\n private function isPhoneNumberOfTeamMember(string $phone): bool\n {\n $teamRepository = app(TeamRepository::class);\n $user = $teamRepository->findTeamMemberByPhone($this->team, $phone);\n\n if ($user instanceof User) {\n return true;\n }\n\n return false;\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->profile->id . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /** Determine the CRM Objects which represent the call activity. */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n if ($this->profile === null) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $sosl = $queryBuilder->buildMatchByQuery($name, 'name');\n if ($sosl === null) {\n return false;\n }\n\n try {\n $objects = $this->queryHandler->search($sosl);\n } catch (NoResultsException $e) {\n return false;\n }\n\n $objects = $this->queryHandler->prioritiseResults(\n $objects,\n $name,\n QueryHandler::PRIORITISE_NAME\n );\n\n $data = $this->convertCrmData($objects, $userId);\n\n return (! empty(array_filter($data))) ? $data : false;\n });\n\n return is_array($result) ? $result : null;\n }\n\n /**\n * @return array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n protected function convertCrmData(QueryIterator $objects, ?int $userId = null): array\n {\n $lead = null;\n $contact = null;\n $opportunity = null;\n $account = null;\n $stage = null;\n $countryCode = null;\n\n if ($objects->count() > 0) {\n $object = $objects->current();\n\n if ($object['attributes']['type'] === 'Lead') {\n $lead = $this->importLead($object);\n\n // Lead might not be imported if the Stage is null for example.\n if ($lead) {\n $countryCode = $lead->country_code;\n $stage = $lead->stage;\n }\n } else {\n if ($object['attributes']['type'] === 'Contact') {\n $contact = $this->importContact($object);\n $account = $contact?->account;\n } else {\n $account = $this->importAccount($object);\n }\n\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account) {\n $countryCode = $account->country_code;\n }\n\n try {\n $sfOpportunities = $this->findOpportunities(\n $account?->getCrmProviderId(),\n $contact?->getCrmProviderId(),\n $userId\n );\n\n // Take the first opportunity, which will be ordered as priority based on their settings.\n if (! empty($sfOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($sfOpportunities[0]['crmId']);\n $stage = $opportunity?->stage;\n }\n } catch (Exception) {\n // Nothing to see here.\n }\n }\n }\n\n return [\n $lead,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n /**\n * @inheritdoc\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n if ($stage->type === Stage::TYPE_LEAD) {\n $objectType = 'Lead';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'Status';\n } else {\n $objectType = 'Opportunity';\n $objectId = $crmObject->crm_provider_id;\n $objectStageType = 'StageName';\n }\n\n $headers = [];\n if ($this->config->trigger_assignment_rules === false) {\n // @see: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/headers_autoassign.htm\n $headers = [\n 'Sforce-Auto-Assign' => 'false',\n ];\n }\n\n $this->updateRecord($objectType, $objectId, [$objectStageType => $stage->name], $headers);\n }\n\n public function parseObjectType(string $objectId): string\n {\n if (Str::startsWith($objectId, '001')) {\n return 'account';\n }\n\n if (Str::startsWith($objectId, '003')) {\n return 'contact';\n }\n\n if (Str::startsWith($objectId, '00Q')) {\n return 'lead';\n }\n\n if (Str::startsWith($objectId, '006')) {\n return 'opportunity';\n }\n\n if (Str::startsWith($objectId, '00U')) {\n return 'event';\n }\n\n if (Str::startsWith($objectId, '00T')) {\n return 'task';\n }\n\n throw new \\InvalidArgumentException('Unsupported Object Type');\n }\n\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, ['profile' => $this->profile]);\n $query = $queryBuilder->buildGetUsersQuery($userToSearch);\n\n try {\n $salesforceUsers = $this->queryHandler->query($query, [\n 'active' => true,\n ]);\n } catch (NoResultsException $e) {\n $this->logger->info('[Salesforce] Sync Profiles. No users found', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n\n $teamRepository = app(TeamRepository::class);\n $customRules = $this->getCustomProfileRules($teamRepository);\n\n foreach ($salesforceUsers as $crmUser) {\n if ($crmUser['Email'] === null) {\n continue;\n }\n\n if (! $this->customProfileValidation($crmUser, $customRules)) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $crmUser['Email']);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $edition = $crmUser['UserPreferencesLightningExperiencePreferred']\n ? Profile::EDITION_LIGHTNING\n : Profile::EDITION_CLASSIC;\n\n $profileRepository = app(ProfileRepository::class);\n $profile = $profileRepository->updateOrCreateProfile(\n $user,\n [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $crmUser['Id'],\n ],\n [\n 'user_id' => $user->getId(),\n 'edition' => $edition,\n 'has_external_cti' => ! empty($crmUser['CallCenterId']),\n 'crm_profile_id' => $crmUser['ProfileId'],\n ]\n );\n\n if ($userToSearch instanceof User && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n // Clean up inactive profiles\n try {\n $this->archiveInactiveProfiles();\n } catch (\\Exception $e) {\n $this->logger->warning('[Salesforce] Profile archiving failed', [\n 'teamId' => $this->team->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n\n // For Salesforce it's easy, we just point every object to the apex domain and they handle it.\n switch ($objectType) {\n case 'lead':\n case 'account':\n case 'contact':\n case 'opportunity':\n case 'task':\n case 'event':\n case 'activity':\n\n $url = $this->config->crm_base_url . '/' . $providerId;\n\n break;\n }\n\n return $url;\n }\n\n public function buildTaskSearchFields(): array\n {\n return ['Id', 'WhoId', 'WhatId', 'AccountId'];\n }\n\n public function getTaskByFilterConditions(\n array $fields,\n array $filters,\n bool $bulkSearch = false,\n bool $strictFilters = true\n ): ?array {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildSearchTaskQuery($fields, $filters, $bulkSearch, $strictFilters);\n\n try {\n if (! $bulkSearch) {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n }\n\n if ($bulkSearch) {\n $objects = $this->queryHandler->query($query);\n $records = [];\n foreach ($objects as $record) {\n $key = $record[end($fields)];\n $records[$key] = $record;\n }\n\n return $records;\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function mapCrmObjects(array $task): array\n {\n $activityData = [];\n\n if (! empty($task['WhoId'])) {\n $type = $this->parseObjectType($task['WhoId']);\n $activityData[$type] = $task['WhoId'];\n }\n if (! empty($task['AccountId'])) {\n $activityData['account'] = $task['AccountId'];\n }\n if (! empty($task['WhatId'])) {\n $activityData['opportunity'] = $task['WhatId'];\n }\n\n return $activityData;\n }\n\n /**\n * Get SF task by Outreach call id.\n */\n public function getTaskByFilter(\n string $activityFieldType,\n array $filters,\n string $operator = '=',\n array $additionalFields = []\n ): ?array {\n $data = [];\n\n try {\n // Default (base) fields.\n $fields = ['Id', 'Subject', 'Description', 'ActivityDate', 'WhoId', 'WhatId', $activityFieldType];\n\n foreach ($additionalFields as $additionalField) {\n $fields[] = $additionalField->crm_provider_id;\n }\n\n $fields = array_unique($fields);\n\n // Find task with the same Outreach id as the call id.\n $query = 'SELECT ' . implode(',', $fields) . '\n FROM Task\n WHERE IsArchived = false AND IsDeleted = false';\n\n foreach ($filters as $key => $value) {\n $key = preg_quote($key, '/');\n $key = str_replace(['\\'', '\"'], '', $key);\n // Prepare the substitution.\n $strKey = \":$key\";\n\n $query .= \" AND $key $operator $strKey\";\n }\n\n $query .= ' ORDER BY LastModifiedDate DESC LIMIT 1';\n\n $objects = $this->queryHandler->query($query, $filters);\n\n // There should be only one task related to this call if any.\n if ($objects->count() === 1) {\n $object = $objects->current();\n\n $dueDate = $object['ActivityDate'] ? Carbon::parse($object['ActivityDate'])->toIso8601String() : null;\n\n $data = array_merge($object, [\n 'crmId' => $object['Id'],\n 'subject' => $object['Subject'],\n 'summary' => $object['Description'],\n 'due' => $dueDate,\n 'Type' => $object[$activityFieldType],\n ]);\n }\n } catch (NoResultsException $e) {\n // Filters don't match any records.\n } catch (ServiceUnavailableException $serviceUnavailableException) {\n // Service cannot be queried. We should probably log this.\n }\n\n return $data;\n }\n\n /**\n * Get Salesforce fields including datetime fields\n *\n * @param $objectType\n */\n private function getAllFieldsAsArray($objectType): array\n {\n $basicFields = [];\n // Not all users have access to all object fields.\n if ($this->profile->{$objectType . '_fields'}) {\n $basicFields = explode(',', $this->profile->{$objectType . '_fields'});\n }\n\n $extraFields = [\n 'CreatedDate',\n 'LastModifiedDate',\n 'IsDeleted',\n ];\n\n if ($objectType === self::OBJECT_OPPORTUNITY\n && $this->config->opportunity_value_field_id\n && ! in_array($this->config->opportunityValueField->crm_provider_id, $basicFields)\n ) {\n $extraFields[] = $this->config->opportunityValueField->crm_provider_id;\n }\n\n return array_unique(array_merge($basicFields, $extraFields));\n }\n\n /**\n * Generate transcription for activity description.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! ($this->config->store_transcript)) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n return $this->transcriptionService\n ->findTranscriptionByActivity($activity)\n ->map(static function (array $transcriptionSegment): string {\n return $transcriptionSegment['formattedStartsAt'] . ' | ' . $transcriptionSegment['transcript'];\n })\n ->implode(PHP_EOL);\n }\n\n /**\n * Find related Salesforce event based on activity data\n *\n * @return array<string>\n */\n public function fetchRelatedActivity(Activity $activity): array\n {\n $this->logger->info('[Salesforce] Searching for related activity', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n ]);\n\n $sfEvent = $this->fetchRelatedEvent($activity);\n if (empty($sfEvent)) {\n $this->logger->info('[Salesforce] No related activity found', [\n 'activityId' => $activity->getUuid(),\n 'ownerId' => $this->profile?->crm_provider_id,\n 'account' => $activity->hasAccount()\n ? $activity->getAccount()->getCrmProviderId()\n : null,\n ]);\n\n return [];\n }\n\n return $sfEvent;\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n if ($activity->isTypeConference() === false) {\n return null;\n }\n\n if ($activity->hasActualStartTime() === false && $activity->hasScheduledStartTime() === false) {\n return null;\n }\n\n if (! $activity->hasProspect()) {\n $this->logger->info('[Salesforce] Skip look up, Activity not linked to Lead, Contact or Account', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n if ($playbook !== null && $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_TASK) {\n $this->logger->info('[Salesforce] Skip auto-sync for task-based playbook', [\n 'activityUuid' => $activity->getUuid(),\n 'playbookId' => $playbook->getId(),\n 'playbookType' => $playbook->getActivityType(),\n ]);\n\n return null;\n }\n\n try {\n $sfEvent = $this->fetchRelatedActivity($activity);\n if (empty($sfEvent)) {\n return null;\n }\n\n [$activityField, $activityType] = $this->resolveActivityTypeFromEvent($activity, $sfEvent);\n\n $this->logger->info('[Salesforce] Found related activity', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'activityFieldName' => $activityField,\n 'crmActivityType' => ($activityField !== null && isset($sfEvent[$activityField]))\n ? $sfEvent[$activityField]\n : null,\n 'activityType' => $activityType,\n ]);\n\n $userId = $this->findRelatedActivityUserId($activity, $sfEvent);\n\n if ($activity->getUserId() !== $userId) {\n $this->logger->info('[Salesforce] Updating meeting owner', [\n 'activityId' => $activity->getUuid(),\n 'oldUserId' => $activity->getUserId(),\n 'newUserId' => $userId,\n ]);\n }\n\n $this->updateSfEventDescription($activity, $sfEvent);\n\n $activity->update([\n 'user_id' => $userId,\n 'crm_provider_id' => $sfEvent['Id'],\n 'playbook_category_id' => $activityType->id ?? $activity->getCategory()?->getId(),\n ]);\n\n $this->logger->info('[Salesforce] Activity updated', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return $activity;\n } catch (\\Exception $exception) {\n \\Sentry::captureException($exception);\n\n throw $exception;\n }\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n *\n * @return array{0: string|null, 1: mixed}\n */\n private function resolveActivityTypeFromEvent(Activity $activity, array $sfEvent): array\n {\n $activityField = $this->getActivityFieldName($activity);\n $activityType = null;\n\n if ($activityField !== null && ! empty($sfEvent[$activityField])) {\n $playbook = $this->getPlaybook($activity->getUser());\n $activityType = $this->getPlaybookCategory($playbook, strval($sfEvent[$activityField]));\n }\n\n return [$activityField, $activityType];\n }\n\n /**\n * @param array<string> $sfEvent\n */\n private function findRelatedActivityUserId(Activity $activity, array $sfEvent): int\n {\n $userId = $activity->getUserId();\n\n if (empty($sfEvent['OwnerId']) === false) {\n $profile = $this\n ->config\n ->profiles()\n ->where('crm_provider_id', $sfEvent['OwnerId'])\n ->get()\n ->filter(static function (Profile $profile) use ($activity): bool {\n if (! $activity->isTypeConference()) {\n return ! empty($profile->user) ? $profile->user->isStatusActive() : false;\n }\n\n $participants = $activity->getParticipants();\n\n return ! empty($profile->user)\n ? $profile->user->isStatusActive()\n && $profile->user->hasPermission(PermissionEnum::RECORD_MEETING)\n && $participants->contains('user_id', $profile->user_id)\n : false;\n })\n ->first();\n\n if ($profile) {\n $userId = $profile->user_id;\n }\n }\n\n return $userId;\n }\n\n /**\n * @param array<string, mixed> $sfEvent\n */\n private function updateSfEventDescription(Activity $activity, array $sfEvent): void\n {\n try {\n if (str_contains($sfEvent['Description'], $activity->id_string)) {\n return;\n }\n\n $payload = [\n 'Description' => $sfEvent['Description']\n . PHP_EOL\n . PHP_EOL\n . new DecorateActivity()->generateDescription($activity),\n ];\n\n $this->logger->info('[Salesforce] Update record', [\n 'activityId' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n 'payload' => $payload,\n ]);\n\n $payload = array_merge(\n $payload,\n $this->payloadBuilder->fetchCustomFieldData($activity, Field::OBJECT_EVENT)\n );\n\n $this->updateRecord('Event', $sfEvent['Id'], $payload);\n } catch (\\Exception) {\n $this->logger->error('[Salesforce] Failed to update record', [\n 'activityUuid' => $activity->getUuid(),\n 'sfEvent' => $sfEvent['Id'],\n ]);\n }\n }\n\n /**\n * Returns the most recently modified Event within time range (if any).\n *\n * @return array|null An Event record from Salesforce.\n */\n private function fetchRelatedEvent(Activity $activity): ?array\n {\n $ownerId = $this->profile?->crm_provider_id;\n if ($ownerId === null) {\n return [];\n }\n\n /** @var ?Carbon $from */\n /** @var ?Carbon $to */\n [$from, $to] = $this->getFromToDates($activity);\n\n try {\n $whoId = null;\n $hasWho = $activity->lead_id || $activity->contact_id;\n if ($hasWho) {\n $whoId = $activity->hasLead()\n ? $activity->getLead()->crm_provider_id\n : $activity->getContact()->crm_provider_id;\n }\n\n if ($hasWho === false && $activity->account_id === null) {\n return null;\n }\n\n $query = $this->buildFetchRelatedEventQuery($activity);\n\n $objects = $this->queryHandler->query($query, [\n 'ownerId' => $ownerId,\n 'whoId' => $whoId,\n 'whatId' => $activity->hasOpportunity() ? $activity->getOpportunity()->crm_provider_id : null,\n 'accountId' => $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null,\n 'from' => $from?->format('Y-m-d\\TH:i:s\\Z'),\n 'to' => $to?->format('Y-m-d\\TH:i:s\\Z'),\n ]);\n\n foreach ($objects as $object) {\n return $object;\n }\n } catch (NoResultsException $e) {\n return [];\n }\n\n return [];\n }\n\n private function getFromToDates(Activity $activity): array\n {\n $from = null;\n $to = null;\n\n /** @var ?CalendarEvent $calendarEvent */\n $calendarEvent = $activity->calendarEvent()->first();\n if ($calendarEvent !== null) {\n $from = $calendarEvent->getStartTime();\n $to = $calendarEvent->getEndTime();\n }\n\n // For non-calendar imported activities\n // Also double check if calendar event dates could be null?\n // If null use what we've got so far\n if ($from === null || $to === null) {\n $from = $activity->hasScheduledStartTime()\n ? $activity->getScheduledStartTime()\n : $activity->getActualStartTime();\n $to = $activity->hasScheduledEndTime()\n ? $activity->getScheduledEndTime()->addMinutes(15)\n : $activity->getActualEndTime();\n }\n\n return [$from, $to];\n }\n\n /**\n * Determines the appropriate activity field name for querying Salesforce events.\n *\n * This method follows a hierarchy to determine the field name:\n * 1. Uses the playbook's activity field if it exists and is in the profile's accessible fields\n * 2. Falls back to the default activity field if the profile has no event fields configured\n * 3. Returns null if no suitable field is found\n *\n * @param Activity $activity The activity to determine the field for\n *\n * @return string|null The field name to use in queries, or null if none is available\n */\n private function getActivityFieldName(Activity $activity): ?string\n {\n if ($this->profile === null) {\n $this->logger->warning('[Salesforce] Cannot determine activity field - profile not found', [\n 'activityId' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n $profileEventFields = $this->profile->getFieldsAsArray('event');\n\n if (empty($profileEventFields)) {\n $defaultActivityField = $this->getDefaultActivityField(Field::OBJECT_EVENT);\n $defaultFieldName = $defaultActivityField?->getAttribute('crm_provider_id');\n // Profile not yet synced — fall back to the default activity field.\n // There is a small chance that the profile won't have Default Activity Type field access\n // in which case the query will fail.\n // This is however an edge case and should be reviewed for profile sync issues.\n Sentry::withScope(function (\\Sentry\\State\\Scope $scope) use ($defaultFieldName): void {\n $scope->setContext('details', [\n 'profileId' => $this->profile->id,\n 'defaultField' => $defaultFieldName,\n ]);\n Sentry::captureMessage(\n '[Salesforce] Profile event fields empty, falling back to default activity field.',\n \\Sentry\\Severity::warning()\n );\n });\n\n return $defaultFieldName;\n }\n\n $playbook = $this->getPlaybook($activity->getUser());\n\n if (! is_null($playbook) && ! is_null($playbook->getActivityField())) {\n $playbookFieldName = $playbook->getActivityField()->getAttribute('crm_provider_id');\n\n if (in_array($playbookFieldName, $profileEventFields, true)) {\n return $playbookFieldName;\n }\n\n $this->logger->warning('[Salesforce] Playbook activity field not found in profile fields', [\n 'activityId' => $activity->getUuid(),\n 'playbookField' => $playbookFieldName,\n 'profileId' => $this->profile->id,\n ]);\n }\n\n return null;\n }\n\n private function buildFetchRelatedEventQuery(Activity $activity): string\n {\n $hasWho = $activity->lead_id || $activity->contact_id;\n\n $activityFieldName = $this->getActivityFieldName($activity);\n $fields = array_filter(['Id', 'Description', 'OwnerId', $activityFieldName]);\n\n $ownerCondition = '(OwnerId = :ownerId OR CreatedById = :ownerId)';\n\n $query = '\n SELECT ' . implode(',', $fields) . '\n FROM Event\n WHERE ' . $ownerCondition . '\n AND IsArchived = false\n AND IsAllDayEvent = false\n AND StartDateTime >= :from\n AND EndDateTime <= :to\n AND (';\n\n $operator = '';\n if ($activity->account_id) {\n // This covers events tied to a related contact or opportunity too.\n $query .= 'AccountId = :accountId';\n\n $operator = ' OR ';\n }\n\n if ($hasWho) {\n $query .= $operator . 'WhoId = :whoId';\n\n // If we are also going to check on a specific opportunity, set that up.\n if ($activity->opportunity_id) {\n $query .= ' OR WhatId = :whatId';\n }\n }\n\n $query .= ') ORDER BY LastModifiedDate DESC';\n\n return $query;\n }\n\n public function fetchProspect(array $task): array\n {\n $lead = $account = $opportunity = $contact = $stage = $countryCode = null;\n $externalId = $task['WhoId'] ?? null;\n\n // Lead or Contact\n if ($externalId) {\n try {\n [$lead, $account, $opportunity, $contact, $stage, $countryCode] = $this->parseRecords($externalId);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n // If we happen to know the opportunity or account from the Task, figure that out.\n if (empty($task['WhatId']) === false) {\n // WhatId could be either Account ID or Opportunity ID.\n // If WhatId is Opportunity ID, get the opportunity and stage from the CRM.\n try {\n [, $account, $opportunity, , $stage, ] = $this->parseRecords($task['WhatId']);\n } catch (\\InvalidArgumentException $exception) {\n // Invalid object type.\n }\n }\n\n return [$lead, $account, $opportunity, $contact, $stage, $countryCode];\n }\n\n /**\n * Save activity transcription summary as note\n */\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n return $this->saveNote($title, $body, (string) $objectId);\n }\n\n public function getObjectByFilterConditions(string $objectType, array $fields, array $filters): ?array\n {\n if ($this->profile === null) {\n return null;\n }\n\n $queryBuilder = app(QueryBuilder::class, [\n 'profile' => $this->profile,\n ]);\n\n $query = $queryBuilder->buildObjectSearchQuery($objectType, $fields, $filters);\n\n try {\n $objects = $this->queryHandler->query($query, $filters);\n if ($objects->count() === 1) {\n return $objects->current();\n }\n } catch (\\Exception $e) {\n $this->logger->info('[Salesforce] Failed to execute query', [\n 'query' => $query,\n 'error' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n private function getCustomProfileRules(TeamRepository $teamRepository): array\n {\n $teamSettings = $teamRepository->getTeamSetting($this->team, 'custom_profile_validation');\n\n if ($teamSettings instanceof TeamSettings && $teamSettings->getValueType() === 'array') {\n $customRules = json_decode($teamSettings->getValue(), true);\n if (is_array($customRules)) {\n return $customRules;\n }\n }\n\n return [];\n }\n\n private function customProfileValidation(array $crmUser, array $customRules): bool\n {\n foreach ($customRules as $customRule) {\n if ($crmUser[$customRule['field']] !== $customRule['value']) {\n return false;\n }\n }\n\n return true;\n }\n\n /**\n * When syncing Contact / Lead / Account / Opportunity / Stage crm entities,\n * validate and restore locally trashed objects,\n * before updating them. Objects are identified by CrmProviderId\n */\n private function restoreAnyTrashedEntity(HasMany $targetEntity, string $crmProviderId): void\n {\n $recordExists = $targetEntity->withTrashed()->where(['crm_provider_id' => $crmProviderId])->first();\n if ($recordExists && $recordExists->trashed()) {\n $recordExists->restore();\n }\n }\n\n #[\\Override] public function supportsNotes(): bool\n {\n return true;\n }\n\n private function getOwnerProfile(?string $ownerId): ?Profile\n {\n if ($ownerId === null) {\n return null;\n }\n\n return $this->config->profiles()\n ->where('crm_provider_id', $ownerId)\n ->first();\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Execute","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Explain Plan","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Browse Query History","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"View Parameters","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open Query Execution Settings…","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"In-Editor Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Tx: Auto","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Cancel Running Statements","depth":4,"on_screen":true,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Playground","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"jiminny","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1556586073691613239
|
-7851921920897086523
|
visual_change
|
accessibility
|
NULL
|
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
Sync Changes
Hide This Notification
Code changed:
Hide
12
246
3
21
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Salesforce;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Jiminny\Component\Country\CountriesMap;
use Jiminny\Contracts\Acl\PermissionEnum;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\ImportsBusinessProcessesInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceBatchSyncInterface;
use Jiminny\Contracts\Services\Crm\Provider\SalesforceInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\RemoteNoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SearchTaskInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SupportsObjectTypeParseInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmProfileRecordTypesInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Enums\CrmObject;
use Jiminny\Events\Activities\Crm\LeadConverted;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpBadRequestException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Exceptions\NoResultsException;
use Jiminny\Exceptions\ServiceUnavailableException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Jobs\Crm\MatchActivitiesToNewOpportunity;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Calendar\CalendarEvent;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Configuration;
use Jiminny\Models\Crm\ContactRole;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Crm\RecordType;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\TeamSettings;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\ContactRoleRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\Crm\RecordTypeFieldValuesRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Helpers\ArrayIterator;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Services\Crm\Salesforce\Fields\FieldHelper;
use Jiminny\Services\Crm\Salesforce\Fields\FieldTypeConverter;
use Jiminny\Services\Crm\Salesforce\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\FollowupActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\LogActivityTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\RecordManipulationsTrait;
use Jiminny\Services\Crm\Salesforce\ServiceTraits\SyncFieldsTrait;
use Jiminny\Utils\CurrencyFormatter;
use Jiminny\Utils\StringUtil;
use Ramsey\Uuid\Uuid;
use Sentry\Laravel\Facade as Sentry;
class Service extends BaseService implements
SalesforceInterface,
SalesforceBatchSyncInterface,
SyncCrmEntitiesInterface,
SyncCrmProfileRecordTypesInterface,
ImportsBusinessProcessesInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SearchTaskInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
SupportsObjectTypeParseInterface,
RemoteNoteEntityManipulationInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncFieldsTrait;
use DeleteObjectsTrait;
use RecordManipulationsTrait;
use ServiceTraits\BatchSyncTrait;
use FollowupActivityTrait;
use LogActivityTrait;
/**
* Note Body Limit for the Old Note-Taking Tool
*
* @var int
*/
private const int CLASSIC_NOTE_MAX_LENGTH = 32000;
/**
* Note Content Limit for the New Notes
*
* @var int
*/
private const int ENHANCED_NOTE_MAX_LENGTH = 50000000;
private const string INSTALLED_PACKAGE_ID = '033Tw0000007bKbIAI';
private const int CACHE_TTL = 600;
private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day - 86400
/**
* @var Client
*/
protected $client;
protected PayloadBuilder $payloadBuilder;
protected QueryHandler $queryHandler;
private OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
public function __construct(
Client $client,
PayloadBuilder $payloadBuilder,
protected Dispatcher $eventDispatcher,
private readonly CountriesMap $countriesMap,
private readonly ProspectPhotoPathService $prospectPhotoPathService,
) {
parent::__construct();
$this->client = $client;
$this->payloadBuilder = $payloadBuilder;
$this->queryHandler = app(QueryHandler::class, [
'client' => $this->client,
'logger' => $this->logger,
]);
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
}
public function getDisplayName(): string
{
return 'Salesforce';
}
public function getJobDelay(): int
{
return 1;
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
return $user->getSocialAccount(SocialAccount::PROVIDER_SALESFORCE);
}
public function verifyTaskExists(Activity $activity): bool
{
$crmProviderId = $activity->getCrmProviderId();
$cacheKey = "crm_task_exists:{$this->config->getId()}:$crmProviderId";
return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($activity, $crmProviderId) {
$playbook = $this->getPlaybookFromActivity($activity);
if ($playbook === null) {
$this->logger->warning('[Salesforce] Cannot verify task - no playbook found', [
'activity' => $activity->getId(),
'crm_provider_id' => $crmProviderId,
]);
return false;
}
$objectType = $playbook->getActivityType() === Playbook::ACTIVITY_TYPE_EVENT ? 'Event' : 'Task';
try {
$record = $this->getRecord($objectType, $crmProviderId, ['Id', 'IsDeleted']);
return ! empty($record) && ($record['IsDeleted'] ?? false) === false;
} catch (HttpNotFoundException|HttpBadRequestException) {
$this->logger->info('[Salesforce] Activity record not found during verification', [
'activity' => $activity->getId(),
'object_type' => $objectType,
'crm_provider_id' => $crmProviderId,
'config_id' => $this->config->getId(),
]);
return false;
}
});
}
public function query(string $queryToRun, array $parameters = []): QueryIterator
{
// Due to poorly designed external calls, this method cannot be entirely removed
return $this->queryHandler->query($queryToRun, $parameters);
}
/*=========== Organization Information ===============*/
/**
* Get a list of all the API Versions for the instance.
*
* @throws CrmException
*
* @return mixed
*
*/
public function getApiVersions()
{
$url = $this->config->crm_base_url . '/services/data';
$response = $this->client->get($url);
return json_decode($response->getBody(), true);
}
/**
* Gets the valid recordTypes for a given Salesforce Object via the describe API.
*/
private function getRecordTypes(string $crmObject): array
{
$url = $this->client->getObjectsUrl() . $crmObject . '/describe';
$response = $this->client->get($url);
$jsonResponse = json_decode($response->getBody(), true);
$fields = [];
foreach ($jsonResponse['recordTypeInfos'] as $row) {
$fields[] = ['recordTypeId' => $row['recordTypeId'], 'default' => $row['defaultRecordTypeMapping']];
}
return $fields;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize($fieldType, $fieldValue, $internal);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
$defaultFields = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::defaultTaskFields()
: FieldDefinitions::defaultEventFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
// Setup the activity field as the default Type.
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'Type',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK, Playbook::ACTIVITY_TYPE_EVENT];
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldFilter = ($activityType === Playbook::ACTIVITY_TYPE_TASK)
? FieldDefinitions::taskFollowupFieldsFilter()
: FieldDefinitions::eventFollowupFieldsFilter();
foreach ($fieldFilter as $eachFilter) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $eachFilter);
// 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();
}
/**
* This one is now called only when ImportActivityTypes is triggered or SyncFieldMetadata executed manually
* Regular sync now uses SharedSyncFieldsTrait -> syncSingleObjectType
* Needs to be replaced later on
*/
public function syncField(Field $field): void
{
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true)
? 'activity'
: $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$metadata = $sfField['Metadata'];
$field->description = mb_strimwidth($metadata['description'] ?? '', 0, 191);
$field->label = mb_strimwidth($metadata['label'] ?? '', 0, Field::LABEL_MAX_LENGTH);
$field->type = $this->convertFieldType($metadata['type'], $field->getEntityName());
$field->is_mandatory = ($metadata['required'] === true);
$field->length = $metadata['length'];
$field->default_value = mb_strimwidth(trim($metadata['defaultValue'] ?? '', '"'), 0, 191);
$field->save();
} else {
$query = '
SELECT
Id, DataType, DeveloperName, Label, Length, Description
FROM
FieldDefinition
WHERE
DurableId = :entityName';
$entityName = $field->getEntityName();
$sfFields = $this->queryHandler->metadata($query, [
'entityName' => $entityName,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$convertedType = $this->convertFieldType($sfField['DataType'], $entityName);
$label = mb_strimwidth($sfField['Label'], 0, Field::LABEL_MAX_LENGTH);
if ($field->isBusinessType()) {
$label = 'Opportunity Type';
}
$field->description = mb_strimwidth($sfField['Description'], 0, Field::DESCRIPTION_MAX_LENGTH);
$field->label = $label;
$field->type = $convertedType;
$field->length = $sfField['Length'];
$field->save();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
private function convertFieldType(string $from, ?string $entityName = null): string
{
$converter = new FieldTypeConverter();
return $converter->convert($from, $entityName);
}
/**
* @inheritdoc
*/
public function importPicklistValues(Field $field): array
{
$values = [];
$fieldValues = [];
try {
if (FieldHelper::isCustomField($field)) {
$query = '
SELECT
Id, Metadata, TableEnumOrId
FROM
CustomField
WHERE
DeveloperName = :fieldName
AND
TableEnumOrId = :fieldType
AND
NamespacePrefix = :namespacePrefix';
// We need to constrain the field lookup to the object, in case it's used in multiple places.
$objectType = \in_array($field->object_type, [Field::OBJECT_TASK, Field::OBJECT_EVENT], true) ?
'activity' : $field->object_type;
$sfFields = $this->queryHandler->metadata($query, [
'fieldName' => substr($field->crm_provider_id, 0, -\strlen('__c')),
'fieldType' => ucfirst($objectType),
// This is used to ensure we only consider the field within the org, not installed packages.
'namespacePrefix' => 'null',
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
$valueSet = $sfField['Metadata']['valueSet'];
if ($valueSet['valueSetName'] === null) {
// Local picklist values can be obtained easily.
$picklistValues = $valueSet['valueSetDefinition']['value'];
} else {
// But for some fields, we just get the Global Value Picklist pointer so need to do more work.
$picklistValues = $this->importGlobalValuePicklistValues($valueSet['valueSetName']);
}
// Import all active values.
foreach ($picklistValues as $i => $sfFieldValue) {
// Setup default value.
if ($sfFieldValue['default']) {
$field->update(['default_value' => $sfFieldValue['valueName']]);
}
// This comes through as null if active (lol).
if ($sfFieldValue['isActive'] !== false) {
$values[] = [
'value' => $sfFieldValue['valueName'],
'label' => $sfFieldValue['valueName'],
'sequence' => $i,
'is_default' => $sfFieldValue['default'],
];
}
}
} else {
$objectFields = $this->getObjectFields($field->object_type);
$fieldId = $field->crm_provider_id;
// Only work with our field of interest.
$objectField = array_filter($objectFields, function ($item) use ($fieldId) {
return $item['name'] === $fieldId;
});
$objectField = array_shift($objectField);
if (empty($objectField['picklistValues']) === false) {
foreach ($objectField['picklistValues'] as $i => $sfFieldValue) {
// Skip inactive values.
if ($sfFieldValue['active'] === false) {
continue;
}
// Setup default value.
if ($sfFieldValue['defaultValue']) {
$field->update(['default_value' => $sfFieldValue['value']]);
}
$values[] = [
'value' => $sfFieldValue['value'],
'label' => $sfFieldValue['label'],
'sequence' => $i,
'is_default' => $sfFieldValue['defaultValue'],
];
}
}
}
$fieldsToPurge = $field->values()->get()->pluck('value')->toArray();
foreach ($values as $value) {
$value['value'] = substr($value['value'] ?? '', 0, 255);
$fieldValues[] = $field->values()->updateOrCreate([
'value' => $value['value'],
], $value);
// Remove this value from the ones we are going to purge.
if (($key = array_search($value['value'], $fieldsToPurge, true)) !== false) {
unset($fieldsToPurge[$key]);
}
}
// Delete the old values that are no longer used.
// Get IDs of the values to be deleted
$valuesToDelete = $field->values()->whereIn('value', $fieldsToPurge);
$valuesToDeleteIds = $valuesToDelete->pluck('id');
if (! $valuesToDeleteIds->isEmpty()) {
$recordTypeFieldValuesRepository = app(RecordTypeFieldValuesRepository::class);
$recordTypeFieldValuesRepository->deleteForCrmFieldValueIds($valuesToDeleteIds->toArray());
// Now safely delete from crm_field_values
$valuesToDelete->delete();
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
return $fieldValues;
}
/**
* Gets values from Global Value Picklists.
*/
private function importGlobalValuePicklistValues(string $picklistName): array
{
$query = '
SELECT
Metadata
FROM
GlobalValueSet
WHERE
DeveloperName = :picklistName
LIMIT 1';
try {
$sfValues = $this->queryHandler->metadata($query, [
'picklistName' => $picklistName,
]);
// There is always 1 result at this point.
$sfValue = $sfValues->current();
return $sfValue['Metadata']['customValue'];
} catch (NoResultsException $noResultsException) {
// Nothing returned.
return [];
}
}
/**
* @inheritdoc
*/
public function syncProfileRecordTypes(): void
{
$objectTypes = [
'lead',
'account',
'contact',
'opportunity',
'task',
'event',
];
foreach ($objectTypes as $objectType) {
try {
$crmRecordTypes = $this->getRecordTypes(ucfirst($objectType));
foreach ($crmRecordTypes as $crmRecordType) {
// If the record type is default and not the Master type, set this.
if ($crmRecordType['default'] && $crmRecordType['recordTypeId'] !== '012000000000000AAA') {
$recordType = $this->config->recordTypes()
->where('crm_provider_id', $crmRecordType['recordTypeId'])
->first();
if ($recordType) {
$this->profile->{$objectType . '_record_type_id'} = $recordType->id;
}
}
}
} catch (HttpNotFoundException $exception) {
Log::error('No access to ' . $objectType . ' object, skipping...');
// XXX: should we log this fact somewhere?
continue;
}
}
if ($this->profile->isDirty()) {
$this->profile->save();
}
}
/**
* Gets business processes.
*/
public function importBusinessProcesses(): void
{
$query = '
SELECT
Id, IsActive, Name, TableEnumOrId
FROM
BusinessProcess
WHERE
TableEnumOrId IN (\'Lead\',\'Opportunity\')';
try {
$sfProcesses = $this->queryHandler->query($query);
// Upsert all processes for the team.
foreach ($sfProcesses as $sfProcess) {
/** @var BusinessProcess $businessProcess */
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $sfProcess['Id'],
], [
'team_id' => $this->team->id,
'name' => $sfProcess['Name'],
'type' => $sfProcess['TableEnumOrId'] === 'Lead' ? 'lead' : 'opportunity',
'is_selectable' => $sfProcess['IsActive'],
]);
$this->importBusinessProcessStages($businessProcess);
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets business process stages.
*/
private function importBusinessProcessStages(BusinessProcess $businessProcess): void
{
$query = '
SELECT
Metadata
FROM
BusinessProcess
WHERE
Id = :processId';
try {
$stages = [];
$sfProcessStages = $this->queryHandler->metadata($query, [
'processId' => $businessProcess->crm_provider_id,
]);
// There is always 1 result at this point.
$sfProcessStage = $sfProcessStages->current();
// Upsert all processes for the team.
foreach ($sfProcessStage['Metadata']['values'] as $sfProcessStage) {
$sanitizedName = urldecode($sfProcessStage['valueName']); // Must decode: "%2C" becomes "," etc.
$stage = $businessProcess->crm->stages()
// This MUST match on label because this API doesn't use API Name.
->where('label', $sanitizedName)
->where('type', $businessProcess->type)
->where('is_selectable', 1)
->first();
if ($stage) {
$stages[] = $stage->id;
}
}
$businessProcess->stages()->sync($stages);
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* Gets record types.
*/
public function importRecordTypes(): void
{
$query = '
SELECT
Id, IsActive, Name, BusinessProcessId, SobjectType
FROM
RecordType';
try {
$sfRecordTypes = $this->queryHandler->query($query);
// Upsert all record types for the process.
foreach ($sfRecordTypes as $sfRecordType) {
$businessProcess = null;
if ($sfRecordType['BusinessProcessId']) {
$businessProcess = $this->config->businessProcesses()
->where('crm_provider_id', $sfRecordType['BusinessProcessId'])
->first();
}
/** @var RecordType $recordType */
$recordType = $this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $sfRecordType['Id'],
], [
'team_id' => $this->team->id,
'type' => mb_strtolower($sfRecordType['SobjectType']),
'name' => $sfRecordType['Name'],
'is_selectable' => $sfRecordType['IsActive'],
'business_process_id' => $businessProcess->id ?? null,
]);
$this->importRecordTypeFieldValues($recordType);
}
} catch (NoResultsException $noResultsException) {
// Do nothing.
}
}
/**
* Import record type - field value mappings. This only works for standard fields.
*/
private function importRecordTypeFieldValues(RecordType $recordType): void
{
try {
$query = '
SELECT
Metadata
FROM
RecordType
WHERE
Id = :recordTypeId';
$sfFields = $this->queryHandler->metadata($query, [
'recordTypeId' => $recordType->crm_provider_id,
]);
// There is always 1 result at this point.
$sfField = $sfFields->current();
// Sync field metadata.
$picklists = $sfField['Metadata']['picklistValues'];
foreach ($picklists as $picklist) {
$field = $this->config->fields()->where([
'type' => Field::TYPE_PICKLIST,
'object_type' => $recordType->type,
'crm_provider_id' => $picklist['picklist'],
])->first();
if ($field) {
$fieldValues = [];
foreach ($picklist['values'] as $value) {
// Must decode: "%2C" becomes "," etc.
$fieldValue = $field->values()
->where('value', urldecode($value['valueName']))
->first();
if ($fieldValue) {
$fieldValues[] = $fieldValue->id;
}
}
$recordType->fieldValues()->sync($fieldValues);
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$params = [];
$missingStage = null;
if ($types === null) {
$types = [Stage::TYPE_LEAD, Stage::TYPE_OPPORTUNITY];
}
foreach ($types as $type) {
if ($type === Stage::TYPE_LEAD) {
$query = '
SELECT
Id, ApiName, MasterLabel, SortOrder
FROM
LeadStatus';
} else {
$query = '
SELECT
Id, ApiName, MasterLabel, IsActive, SortOrder, DefaultProbability
FROM
OpportunityStage';
}
if ($missingStageName) {
$escapedStageName = ValueNormalizer::replaceQueryWithStringLiterals($missingStageName);
$query .= ' WHERE ApiName = :stageName';
$params = [
'stageName' => $escapedStageName,
];
}
try {
$sfStages = $this->queryHandler->query($query, $params);
} catch (NoResultsException $exception) {
$sfStages = [];
}
$missingStage = null;
// Upsert all stages for the team.
foreach ($sfStages as $sfStage) {
$selectable = true;
if (array_key_exists('IsActive', $sfStage)) {
$selectable = $sfStage['IsActive'];
}
$this->restoreAnyTrashedEntity($this->config->stages(), $sfStage['Id']);
$stage = $this->config->stages()->updateOrCreate([
'crm_provider_id' => $sfStage['Id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($sfStage['ApiName'], 0, 50),
'label' => mb_strimwidth($sfStage['MasterLabel'], 0, 191),
'type' => $type,
'sequence' => $sfStage['SortOrder'] ?? 0,
'is_selectable' => $selectable,
'probability' => $sfStage['DefaultProbability'] ?? null,
]);
if ($missingStageName && $missingStageName === $sfStage['ApiName']) {
$missingStage = $stage;
}
}
if ($missingStageName && $missingStage === null) {
// If they requested a stage that still doesn't exist, it must be inactive so lazy create it.
$missingStage = $this->config->stages()->create([
'crm_provider_id' => Uuid::uuid4(),
'team_id' => $this->team->id,
'name' => mb_strimwidth($missingStageName, 0, 50),
'label' => mb_strimwidth($missingStageName, 0, 191),
'type' => $type,
'sequence' => 0,
'is_selectable' => 0,
]);
}
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncLeads(Carbon $since, ?Carbon $to = null, ?string $crmProfileId = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('lead');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Lead
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfLeads = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfLeads as $sfLead) {
// Only sync if previously imported.
if ($this->hasLead($sfLead['Id'])) {
$this->importLead($sfLead);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::LEAD);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncLead(string $crmId): ?Lead
{
$fields = $this->getAllFieldsAsArray('lead');
$sfLead = $this->getRecord('Lead', $crmId, $fields);
return $this->importLead($sfLead);
}
private function importLead($crmData): ?Lead
{
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['Status'])) {
// Get the current stage.
$stage = $this->config
->stages()
->where('name', $crmData['Status'])
->where('type', Stage::TYPE_LEAD)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_LEAD], $crmData['Status']);
}
}
// If we have no way of importing this, just return null :(
if ($stage === null) {
return null;
}
$countryCode = $crmData['CountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country name.
if ($countryCode === null && empty($crmData['Country']) !== false) {
$countryCode = $this->convertCountryNameToCode($crmData['Country']);
}
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'] ?? '', 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
$mobilePhone = null;
if (empty($crmData['MobilePhone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['MobilePhone'], 0, 25);
$mobilePhone = phone_e164($countryCode, $number);
}
$convertedDate = null;
$convertedAccount = null;
$convertedOpportunity = null;
$convertedContact = null;
if ($crmData['IsConverted'] == 'true') {
$convertedDate = $crmData['ConvertedDate'];
if (empty($crmData['ConvertedAccountId']) === false) {
$convertedAccount = $this->config
->accounts()
->where('crm_provider_id', $crmData['ConvertedAccountId'])
->first();
if ($convertedAccount === null) {
try {
$convertedAccount = $this->syncAccount($crmData['ConvertedAccountId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedOpportunityId']) === false) {
$convertedOpportunity = $this->config
->opportunities()
->where('crm_provider_id', $crmData['ConvertedOpportunityId'])
->first();
if ($convertedOpportunity === null) {
try {
$convertedOpportunity = $this->syncOpportunity($crmData['ConvertedOpportunityId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
if (empty($crmData['ConvertedContactId']) === false) {
$convertedContact = $this->team
->crm
->contacts()
->where('crm_provider_id', $crmData['ConvertedContactId'])
->first();
if ($convertedContact === null) {
try {
$convertedContact = $this->syncContact($crmData['ConvertedContactId']);
} catch (HttpNotFoundException $exception) {
// Probably the user has no permissions to access the converted data.
}
}
}
}
if (empty($crmData['Company'])) {
$company = 'Unknown';
} else {
$company = mb_strimwidth($crmData['Company'], 0, 191);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'] ?? '',
'company' => $company,
'domain' => $domain,
'name' => $crmData['Name'] ? mb_strimwidth($crmData['Name'], 0, 191) : '',
'title' => $crmData['Title'] ? mb_strimwidth($crmData['Title'], 0, 128) : null,
'email' => $crmData['Email'] ? mb_strimwidth($crmData['Email'], 0, 80) : null,
'phone' => $parsedNumber['phone'],
'ext' => $parsedNumber['ext'] ?? null,
'mobile_phone' => $mobilePhone,
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Lead::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'stage_id' => $stage->id,
'record_type_id' => null,
'converted_at' => $convertedDate,
'converted_account_id' => $convertedAccount->id ?? null,
'converted_opportunity_id' => $convertedOpportunity->id ?? null,
'converted_contact_id' => $convertedContact->id ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->leads(), $crmData['Id']);
/** @var Lead $lead */
$lead = $this->config->leads()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
if ($lead->wasChanged('converted_at') && $lead->getConvertedAt() !== null) {
$this->eventDispatcher->dispatch(new LeadConverted($lead));
}
$this->handleObjectDeletion($lead, $crmData);
if ($lead->trashed()) {
return null;
}
return $lead;
}
/**
* @inheritdoc
*/
public function syncAccounts(Carbon $since, ?Carbon $to = null): int
{
$syncCount = 0;
$fields = $this->getAllFieldsAsArray('account');
if (\in_array('Id', $fields, true) === false) {
return $syncCount;
}
$query = '
SELECT ' . rtrim(implode(',', $fields), ',') . '
FROM Account
WHERE LastModifiedDate > :since
ORDER BY LastModifiedDate ASC';
try {
$sfAccounts = $this->queryHandler->query($query, [
'since' => $since->format('Y-m-d\TH:i:s\Z'),
]);
foreach ($sfAccounts as $sfAccount) {
// Only sync if previously imported.
if ($this->hasAccount($sfAccount['Id'])) {
$this->importAccount($sfAccount);
$syncCount++;
}
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::ACCOUNT);
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncAccount(string $crmId): ?Account
{
$fields = $this->getAllFieldsAsArray('account');
if (! in_array('Id', $fields, true)) {
$this->logger->info('[Salesforce] Sync account cancelled. Fields are not available.', [
'crmId' => $crmId,
'userId' => $this->profile->getUserId(),
]);
return null;
}
$sfAccount = $this->getRecord('Account', $crmId, $fields);
return $this->importAccount($sfAccount);
}
private function importAccount($crmData): ?Account
{
if (! empty($crmData['IsDeleted'])) {
$this->handleEntityDeletionByProviderId($this->config->accounts(), $crmData);
return null;
}
$countryCode = $crmData['BillingCountryCode'] ?? $crmData['ShippingCountryCode'] ?? null;
// Salesforce allows custom "countries" to be created. Disregard these.
if ($countryCode && $this->countriesMap->countryExists($countryCode) === false) {
$countryCode = null;
}
// If we have no country code, try to parse it from the country names.
if ($countryCode === null && empty($crmData['BillingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['BillingCountry']);
}
if ($countryCode === null && empty($crmData['ShippingCountry']) === false) {
$countryCode = $this->convertCountryNameToCode($crmData['ShippingCountry']);
}
if (empty($crmData['Phone']) === false) {
// Trim to our width and attempt to parse it.
$number = mb_strimwidth($crmData['Phone'], 0, 25);
$parsedNumber = parsePhoneNumber($countryCode, $number);
} else {
$parsedNumber = [];
}
$industry = null;
if (empty($crmData['Industry']) === false) {
$industry = mb_strimwidth($crmData['Industry'], 0, 40);
}
$domain = null;
if (empty($crmData['Website']) === false) {
$domain = mb_strimwidth($crmData['Website'], 0, 191);
$domain = StringUtil::resolveDomain($domain);
}
$createdDate = null;
if (empty($crmData['CreatedDate']) === false) {
$createdDate = Carbon::parse($crmData['CreatedDate'])->setTimezone('UTC');
}
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$data = [
'team_id' => $this->team->id,
'user_id' => $profile?->user_id,
'owner_id' => $crmData['OwnerId'],
'name' => mb_strimwidth($crmData['Name'], 0, 191),
'photo_path' => $this->prospectPhotoPathService->getOrGeneratePhotoPath(
crmConfiguration: $this->config,
crmProviderId: $crmData['Id'],
modelType: Account::class,
fileName: $crmData['Id'],
avatarText: $crmData['Name']
),
'industry' => $industry,
'domain' => $domain,
'phone' => $parsedNumber['phone'] ?? null,
'ext' => $parsedNumber['ext'] ?? null,
'country_code' => $countryCode,
'remotely_created_at' => $createdDate,
];
$this->restoreAnyTrashedEntity($this->config->accounts(), $crmData['Id']);
/** @var Account $account */
$account = $this->config->accounts()->updateOrCreate(['crm_provider_id' => $crmData['Id']], $data);
$this->handleObjectDeletion($account, $crmData);
return $account->trashed() ? null : $account;
}
/**
* @inheritdoc
*/
public function syncOpportunities(array $parameters, ?string $strategy = null): int
{
$strategies = $this->opportunitySyncStrategyResolver->getStrategies($this->config, $strategy);
$syncCount = 0;
$logParams = $parameters;
$parameters['profile'] = $this->profile;
$logParams['user'] = $this->profile->getUserId();
if (count($strategies) > 1) {
$this->logger->warning('[' . $this->getDisplayName() . '] Multiple sync strategies used', [
'teamId' => $this->team->getUuid(),
'params' => $logParams,
'strategies_count' => count($strategies),
]);
}
foreach ($strategies as $syncStrategy) {
$name = $syncStrategy->getStrategyName();
try {
$sfOpportunities = $syncStrategy->fetchOpportunities($parameters);
$totalRecords = $sfOpportunities->count();
foreach ($sfOpportunities as $sfOpportunity) {
$this->importOpportunity($sfOpportunity);
$syncCount++;
}
} catch (NoResultsException $noResultsException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] No opportunities found', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $noResultsException->getMessage(),
]);
} catch (CrmException $crmException) {
// Nothing to sync.
$this->logger->warning('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->getUuid(),
'name' => $name,
'params' => $logParams,
'reason' => $crmException->getMessage(),
]);
}
}
$this->syncRemotelyDeletedObjectsWithErrorHandling(CrmObject::OPPORTUNITY, ['params' => $logParams]);
// debug to see how if count of opportunities reaches 1000
if ($syncCount >= 1000) {
$this->logger->info(
'[' . $this->getDisplayName() . '] Sync Opportunities - count warning',
[
'team_id' => $this->team->getId(),
'params' => $logParams,
'count' => $syncCount,
'strategies_count' => count($strategies),
'total_records' => $totalRecords ?? null,
]
);
}
return $syncCount;
}
/**
* @inheritdoc
*/
public function syncOpportunity(string $crmId): ?Opportunity
{
$strategy = $this->opportunitySyncStrategyResolver->resolve(
$this->config,
OpportunitySyncStrategyResolver::SINGLE_SYNC_OPPORTUNITY_STRATEGY
);
$parameters = [
'profile' => $this->profile,
'crm_id' => $crmId,
];
try {
$sfOpportunity = $strategy->fetchOpportunities($parameters);
} catch (HttpNotFoundException $e) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity not found', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
]);
return null;
} catch (CrmException $crmException) {
$this->logger->info('[' . $this->getDisplayName() . '] Opportunity sync failed', [
'teamId' => $this->team->id_string,
'crmId' => $crmId,
'exception' => $crmException->getMessage(),
]);
return null;
}
if ($sfOpportunity instanceof ArrayIterator) {
return $this->importOpportunity($sfOpportunity->getItems());
}
return $this->importOpportunity($sfOpportunity);
}
/**
* @throws HttpNotFoundException
*/
private function importOpportunity($crmData): ?Opportunity
{
$profile = $this->getOwnerProfile($crmData['OwnerId'] ?? null);
$account = null;
if (empty($crmData['AccountId']) === false) {
/** @var ?Account $account */
$account = $this->config->accounts()
->where('crm_provider_id', (string) $crmData['AccountId'])
->first();
if ($account === null) {
$account = $this->syncAccount($crmData['AccountId']);
}
}
$userId = $profile?->getUserId() ?? $account?->getUserId();
if ($userId === null) {
$this->logger->error('[Salesforce] | Skip import, no user_id found', [
'id' => $crmData['Id'],
]);
return null;
}
/** @var ?Stage $stage */
$stage = null;
if (isset($crmData['StageName'])...
|
86076
|
NULL
|
NULL
|
NULL
|
|
86076
|
2954
|
1
|
2026-05-28T13:44:09.026906+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-28/1779 /Users/lukas/.screenpipe/data/data/2026-05-28/1779975849026_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
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'...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"#12121 on JY-20963-fix-import-on-deleted-entity, menu","depth":5,"on_screen":true,"help_text":"Pull request #12121 exists for current branch JY-20963-fix-import-on-deleted-entity, but pull request details loading failed","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"ServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'ServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-5925516468550469094
|
-8643040614315223040
|
visual_change
|
hybrid
|
NULL
|
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'
iTerm2ShellEditViewSessionScriptsProfilesWindowHelp‹$0(ahl100% C78 • Thu 28 May 16:44:08DOCKER881DEV (docker)₴2-zsh#3XIt1DOCKER (docker-compose)csearch""data"],"pid":7,"message": "Unable to reviveconnection: [URL_WITH_CREDENTIALS] : ["warning", "elasti"message":"No living connections"}{"type": "log""@timestamp": "2026-05-28T13:44:O0Z", "tags": ["error","plugins""taskManager"],"pid" :7, "message" : "Failed to poll for work: Error: No Living cdocker_lamp_12026-05-28 13:43:55 Running ['artisan' mailbox:batch:process --max-batche5=15]6s DONEdocker_lamp_111 '/usr/local/bin/php' 'artisan'mailbox:batch:process--max-batches=15 >'/proc/1/fd/1' 2>&1docker_lamp_12026-05-28 13:44:01 Running ['artisan' mailbox:batch:retry-failed --max-batches=15] in background4.62ms DONEdocker_lamp_11" ('/usr/local/bin/php' 'artisan' mailbox:batch:retry-failed--max-batche5=15 >'/proc/1/fd/1' 2>&1 ; '/usr/local/bin/php' 'artisan'ule-390defd641effba0f73a895e426ded4cf2ba7f11"schedule:finish"framework/sched"$?")> '/dev/null'2>&1 &kibanaI {"'type": "log""@timestamp":"2026-05-28T13:44:03Z", "tags" : ["error","elasticsearch", "data"], "pid":7, "message" : "[ConnectionError]: getaddrinfo ENOTFOUNDelasticsearch elasticsearch:9200"}kibana1 {"type": "log"., "@timestamp": "2026-05-28T13:44:03Z","tags" : ["warning","elasticsearch", "data"], "pid" :7,"message": "Unable to revive connection: [URL_WITH_CREDENTIALS] "2026-05-28T13:44:03Z" , "tags" : ["warning", "elasticsearch", "data"], "pid":7, "message": "No living connections"}kibana"taskManager"1 {"type":"log"., "@timestamp": "2026-05-28T13:44:03Z", "tags":["error","plugins"onnections"}',"taskManager"], "pid" :7, "message": "Failed to poll for work:Error: No Living ckibana1 {"'type": "1og".,"@timestamp":"2026-05-28T13:44:05Z", "tags": ["error","elasticsearch","data"], "pid" :7, "message": "[ConnectionError]: getaddrinfo ENOTFOUND elasticsearch elasticsearch:9200'}kibana1 {"'type": "log".,"@timestamp":"2026-05-28T13:44:06Z",,"tags" : ["warning", "elasticsearch", "data"], "pid":7, "message": "Unable to revive connection: [URL_WITH_CREDENTIALS] : ["warning", "elasticsearch", "data"],"pid":7, "message": "No living connections"}1 {"'type": "log"."tags": ["error"kibaskManagen', tastMan er g, etines tness: e 1 605287to poit 7, wor Eeori no Llugins"kibana1 {"type": "log"., "@timestamp": "2026-05-28T13:44:08Z", "tags": ["error","elasticsearch", "data"], "pid" :7, "message" : "[ConnectionError]: getaddrinfo ENOTFOUND elasticsearch elasticsearch:9200"3ec2-user@ip-10-30-129-...886ec2-user@ip-10-30-140-...₴7PROD*** System restart required ***Last login: Wed May 27 06:47:29 2026 from 212.5.153.87lukas@jiminny-prod-bastion:~$13EU (ssh)Enable ESM Apps to receive additional future security updates.See [URL_WITH_CREDENTIALS] |T4 STAGE (-zsh)*** System restart required ***Last login: Mon May 18 06:46:53 2026 from 212.5.153.87ion: $ client_loop: send disconnect: Broken pipelukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~$ 0Y5QA (-zsh)STAGE~$0T6FE (-zsh)lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ lFRONTENDX T7 EXT (-zsh)Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~ $ [EXTENSIONv View in Docker Desktop• View ConfigL View Logsw Enable Watchd Detach...
|
NULL
|
NULL
|
NULL
|
NULL
|